feat(web): freeze dashboard data contract

This commit is contained in:
fy59 2026-09-16 13:37:35 +02:00
parent 6443221327
commit f894f744f3
18 changed files with 569 additions and 30 deletions

View file

@ -120,7 +120,9 @@ and run-scoped terminal planes keep Notcurses infrastructure separate from
product semantics.
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` resolves the former Dashboard
layering violation. `tui/src/dashboard.c` alone owns its SQLite fact projection,
layering violation. `tui/src/dashboard.c` owns its frozen TUI-compatible SQLite
projection; `tui/src/web_dashboard.c` owns the distinct Web aggregate and
composes that projection only where semantics match.
normalization, bounds, ordering, rolling windows, aggregates and legacy-data
flags. Its public `trainlog/dashboard.h` boundary contains no SQLite, Notcurses
or private TUI type. The TUI calls that service with an explicit reference Unix
@ -298,13 +300,53 @@ It does not enter business SQLite V1, `TRAINLOG_FORMAT_V1`, scientific or AI
exports, or session synchronization. Small-screen layouts are derived
responsive projections, not a second persisted truth.
Dashboard values come only from real Trainlog data or documented deterministic
Core calculations. Missing data produces an unavailable/empty state. Prochaine
séance displays only a genuinely prepared and persisted session; a generated,
AI, hidden, or unaccepted proposal is not a validated session. Cardio /
récupération remains unavailable until real relevant data exists. The exact
Activité and Progression mathematics remain open and must be specified before
their read models are implemented.
`WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN` fixes one coherent, read-only
`GET /api/v1/dashboard` snapshot. One outer SQLite read savepoint covers every
projection; statements are finalized before return. The seven domains mean:
1. **Prochaine séance** is available only for a persisted plan explicitly
accepted for execution. Schema v18 has proposals, targets in occurrences and
completed/observable history, but no such lifecycle gate. V1 therefore
returns `available=false`, reason `no_persisted_executable_plan`; proposals,
templates and generated suggestions never qualify.
2. **Activité** is the 90 local calendar days ending on the backend's current
local date. A day is active when at least one observable session starts on
that persisted timestamp's local date. Observable means at least one
performed set, continuous activity or explicit MAX; `ended_at` is not a
gate. Multiple sessions are counted separately and sets are counted without
converting continuous/MAX facts into fake sets.
3. **Progression** reuses the frozen Core Dashboard selection and exact
`(exercise_id,equipment_id,tracking_mode,load_mode,dose)` identity over 90
days. Points and deterministic improvement events never mix identities.
Frozen legacy present-at-0.0-kg behavior remains unchanged.
4. **Dernière séance** is the newest observable session by represented instant,
then stable session ID. Duration exists only with a valid non-earlier
`ended_at`. Counts distinguish worked occurrences, persisted sets,
continuous occurrences and explicit MAX. Primary zones come only from
occurrences that own one of those facts, deduplicated and ordered by
`zone_id`; catalogue-only exercises never contribute.
5. **Records / MAX** contains at most eight `max_results`, newest first with
session and occurrence IDs as deterministic ties. It exposes the source
exercise/equipment and measured weight; ordinary sets and estimated 1RM
never qualify.
6. **Répartition musculaire** covers the 30 local calendar days ending today.
Each persisted primary zone reports distinct observable sessions, worked
occurrences and actual sets. Continuous and MAX occurrences count as work
occurrences/sessions but add no set. Secondary zones and recovery scores
are excluded.
7. **Cardio / récupération** is unavailable with stable reason
`no_cardio_data_source`; duration, load, feedback and continuous activity
are not cardio proxies.
The same snapshot supplies the Unix account name (bounded `getpwuid_r`
fallback), latest observable session timestamp and that session's worked
primary-zone labels to the Footer. Absence is represented by `null`, empty
collections or stable unavailable reasons, never invented zero-valued facts.
The JSON writer is internal, allocation-free and bounded at 128 KiB; it owns
escaping for quotes, backslashes, controls and UTF-8. Collections are bounded
to 90 activity days, eight MAX entries, 17 BODY ZONES, 128 progression points
and a 4,096-session corruption scan. `meta.partial` reports truncation and
`meta.invalid_data` reports rejected persisted values independently.
V1 security invariants are: loopback-only binding, Host validation, no
permissive CORS, foreign-origin/CSRF protection for mutations, explicit bounds

View file

@ -133,7 +133,8 @@ Rendering is not permitted to own SQL or business rules. Statistics are
read-only projections; they are not persisted as facts. The former Dashboard
exception is resolved: its typed Core read model owns the SQLite projection and
all characterized calculations, while the TUI supplies query state and renders
the result. No Dashboard HTTP endpoint exists yet.
the result. The Web aggregate now exposes the distinct factual snapshot through
`GET /api/v1/dashboard` without changing the frozen TUI projection.
## Local Web
@ -143,8 +144,9 @@ security invariants are canonical. The CLI/local HTTP infrastructure and
embedded frontend shell are implemented: `-w`/`--web`, optional `--port`, exact
IPv4 loopback binding, `/api/v1/health`, React navigation and the persistent
Header/Body/Footer shell. Production assets are generated from the npm lockfile
and linked into the binary. No Dashboard business route, real metric, grid
engine, business mutation, browser launch or layout persistence exists yet.
and linked into the binary. The read-only Dashboard business snapshot and
factual Footer integration exist; no grid engine, business mutation, automatic
browser launch or layout persistence exists yet.
## Data semantics
@ -185,6 +187,10 @@ including TypeScript typecheck, 10 Vitest component tests, deterministic asset
generation bounds, real embedded-asset HTTP coverage, SPA/API separation and
runtime execution without Node/npm or `web/dist`.
The Dashboard data-contract tranche passes **71/71 normal and ASan/UBSan
Meson tests**, including the bounded Core/JSON target, HTTP endpoint coverage
and 12 Vitest tests for parsing and factual Footer integration.
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus
Android resource-parity, language-owner, typed sync-presentation, and
@ -197,9 +203,9 @@ smoke test are not automated. The latter is why
## Active limitations
- `TRAINLOG_WEB_V1` has its Core prerequisite, CLI/HTTP infrastructure and
embedded frontend shell. The next cursor begins the explicitly partitioned
Dashboard V1 work; no Dashboard HTTP route or real Web metric exists yet.
- `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN`: the bounded Core snapshot,
`/api/v1/dashboard`, explicit unavailable states and factual Footer data are
implemented. The seven tile bodies intentionally retain shell placeholders.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test.

View file

@ -25,13 +25,13 @@ Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft
exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
CLI/HTTP infrastructure and embedded frontend shell exist, but no Dashboard
business endpoint or real Web metric exists.
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract exist. Final tile rendering and layout remain future slices.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_V1
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_GRID_V1
```
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
@ -63,12 +63,13 @@ and session writes require explicit transactional Core command services.
the `127.0.0.1:8080` default, explicit `--port`, no silent port fallback, a
single serialized Core/SQLite owner, build-only Node/npm, and no change to
business SQLite or frozen exchange formats. Dashboard Activité and Progression
mathematics remain specification gates before their read models.
semantics are now frozen by `WEB_DASHBOARD_DATA_CONTRACT_V1`; future rendering
must consume them without reconstructing or redefining their mathematics.
`WEB_DASHBOARD_V1` must advance through independently reviewable slices:
`WEB_DASHBOARD_V1` advances through independently reviewable slices:
```text
missing Dashboard API/read models
WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN]
-> grid-library spike and bounded grid engine
-> separately versioned layout persistence
-> real-data tile delivery

View file

@ -55,6 +55,17 @@ npm run build
Release-oriented Meson builds must use `-Dweb=enabled`; Meson never provisions
or downloads npm dependencies.
`WEB_DASHBOARD_DATA_CONTRACT_V1` adds a standalone bounded Core test for empty
history, explicit unavailable domains, two observable sessions on one local
day, an unfinished session, continuous activity, MAX-only sessions, primary
zone deduplication, catalogue-without-history exclusion, 90/30-day windows,
recent-MAX truncation/`partial`, Footer facts and JSON escaping/overflow. The
HTTP lifecycle test additionally proves `GET /api/v1/dashboard`, JSON content
type/version, unavailable reasons and `POST` rejection without weakening Host,
CSP, CORS or unknown-route behavior. Vitest validates strict parsing, real
Footer population, empty fallback, malformed snapshots and absence of invented
tile metrics; the seven tile bodies remain placeholders.
## 2. Frozen Trainlog JSON v1
Run:

View file

@ -0,0 +1,97 @@
/* Trainlog Web Dashboard V1 typed read model. */
#ifndef TRAINLOG_WEB_DASHBOARD_H
#define TRAINLOG_WEB_DASHBOARD_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "trainlog/dashboard.h"
#define TRAINLOG_WEB_ACTIVITY_DAYS 90U
#define TRAINLOG_WEB_MAX_RECORDS 8U
#define TRAINLOG_WEB_ZONE_CAPACITY TRAINLOG_DASHBOARD_ZONE_CAPACITY
#define TRAINLOG_WEB_JSON_CAPACITY (128U * 1024U)
typedef struct TrainlogWebActivityDay {
char date[11];
bool active;
size_t session_count;
size_t set_count;
} TrainlogWebActivityDay;
typedef struct TrainlogWebWorkedZone {
char zone_id[TRAINLOG_ZONE_ID_MAX + 1U];
char label[TRAINLOG_NAME_MAX + 1U];
size_t session_count;
size_t occurrence_count;
size_t set_count;
} TrainlogWebWorkedZone;
typedef struct TrainlogWebLastSession {
bool available;
char session_id[TRAINLOG_ID_MAX + 1U];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
bool has_ended_at;
char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U];
bool has_duration;
uint64_t duration_seconds;
size_t exercise_count;
size_t set_count;
size_t continuous_count;
size_t max_count;
TrainlogWebWorkedZone zones[TRAINLOG_WEB_ZONE_CAPACITY];
size_t zone_count;
} TrainlogWebLastSession;
typedef struct TrainlogWebMaxRecord {
char session_id[TRAINLOG_ID_MAX + 1U];
char entry_id[TRAINLOG_ID_MAX + 1U];
char exercise_id[TRAINLOG_ID_MAX + 1U];
char exercise_name[TRAINLOG_NAME_MAX + 1U];
char equipment_id[TRAINLOG_ID_MAX + 1U];
double weight_kg;
char timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
} TrainlogWebMaxRecord;
typedef struct TrainlogWebDashboardSnapshot {
bool partial;
bool invalid_data;
char generated_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char user_display_name[TRAINLOG_NAME_MAX + 1U];
/* CONTRACT: no persisted executable plan exists in schema v18. */
bool next_session_available;
const char *next_session_reason;
TrainlogWebActivityDay activity[TRAINLOG_WEB_ACTIVITY_DAYS];
size_t activity_count;
TrainlogDashboardSnapshot progression;
TrainlogWebLastSession last_session;
TrainlogWebMaxRecord max_records[TRAINLOG_WEB_MAX_RECORDS];
size_t max_record_count;
TrainlogWebWorkedZone muscle_zones[TRAINLOG_WEB_ZONE_CAPACITY];
size_t muscle_zone_count;
bool cardio_available;
const char *cardio_reason;
} TrainlogWebDashboardSnapshot;
typedef struct TrainlogWebDashboardQuery {
int64_t reference_unix_second;
} TrainlogWebDashboardQuery;
/* WHY: React must consume business facts, never reconstruct them from rows.
* CONTRACT: all pointers are borrowed for the call; output is fully initialized,
* bounded, deterministic and retains no SQLite statement. One outer read
* snapshot covers every domain. reference_unix_second is an inclusive UTC
* cutoff and also selects the current system-local calendar day.
* INVARIANT: observable work means a performed set, continuous activity or
* explicit max_results row; targets/catalogue rows alone never qualify. */
TrainlogStatus trainlog_web_dashboard_load(TrainlogDatabase *database,
const TrainlogWebDashboardQuery *query,
TrainlogWebDashboardSnapshot *output);
/* Caller owns output. Success guarantees valid NUL-terminated JSON. */
TrainlogStatus trainlog_web_dashboard_serialize(
const TrainlogWebDashboardSnapshot *snapshot,
char *output, size_t capacity, size_t *output_size);
#endif

View file

@ -43,6 +43,7 @@ if web_assets_enabled
'../web/src/main.tsx',
'../web/src/vite-env.d.ts',
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/app/App.tsx',
'../web/src/app/routes.ts',
'../web/src/app/useRoute.ts',
@ -174,6 +175,9 @@ trainlog_core_sources = files(
'src/catalog.c',
'src/database.c',
'src/dashboard.c',
'src/json_writer.c',
'src/web_dashboard.c',
'src/web_dashboard_json.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
@ -298,6 +302,14 @@ test_dashboard = executable(
)
test('dashboard', test_dashboard)
test_web_dashboard = executable(
'test_web_dashboard',
'tests/test_web_dashboard.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('web_dashboard', test_web_dashboard)
test_dashboard_header = executable(
'test_dashboard_header',
'tests/test_dashboard_header.c',

37
tui/src/json_writer.c Normal file
View file

@ -0,0 +1,37 @@
#include "json_writer.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#include <utf8proc.h>
static bool append(TrainlogJsonWriter *w, const char *s, size_t n) {
if (w == NULL || s == NULL || w->failed || n > w->capacity - (w->size < w->capacity ? w->size : w->capacity) - 1U) {
if (w != NULL) w->failed = true; return false;
}
(void)memcpy(w->data + w->size, s, n); w->size += n; w->data[w->size] = '\0'; return true;
}
void trainlog_json_init(TrainlogJsonWriter *w, char *data, size_t capacity) {
if (w == NULL) return; w->data=data; w->capacity=capacity; w->size=0U; w->failed=data==NULL||capacity==0U; if (!w->failed) data[0]='\0';
}
bool trainlog_json_raw(TrainlogJsonWriter *w,const char *v){return v!=NULL&&append(w,v,strlen(v));}
bool trainlog_json_string(TrainlogJsonWriter *w,const char *v){
const unsigned char *p=(const unsigned char *)v; char escape[7];
if(v==NULL||!append(w,"\"",1U))return false;
while(*p!='\0'){
utf8proc_int32_t codepoint;
utf8proc_ssize_t sequence=utf8proc_iterate(p,-1,&codepoint);
/* CONTRACT: valid UTF-8 is preserved byte-for-byte; corrupt storage
* must fail serialization instead of producing invalid JSON. */
(void)codepoint;
if(sequence<=0){w->failed=true;return false;}
if(*p=='\"'||*p=='\\'){escape[0]='\\';escape[1]=(char)*p;if(!append(w,escape,2U))return false;}
else if(*p=='\b'||*p=='\f'||*p=='\n'||*p=='\r'||*p=='\t'){const char codes[]="bfnrt";const char chars[]="\b\f\n\r\t";size_t i;escape[0]='\\';for(i=0U;i<5U&&chars[i]!=(char)*p;++i){}escape[1]=codes[i];if(!append(w,escape,2U))return false;}
else if(*p<0x20U){int n=snprintf(escape,sizeof(escape),"\\u%04x",(unsigned int)*p);if(n!=6||!append(w,escape,6U))return false;}
else if(!append(w,(const char *)p,(size_t)sequence))return false;
p+=(size_t)sequence;
}
return append(w,"\"",1U);
}
bool trainlog_json_size(TrainlogJsonWriter*w,size_t v){char b[32];int n=snprintf(b,sizeof(b),"%zu",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}
bool trainlog_json_i64(TrainlogJsonWriter*w,long long v){char b[32];int n=snprintf(b,sizeof(b),"%lld",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}
bool trainlog_json_double(TrainlogJsonWriter*w,double v){char b[64];int n;if(!isfinite(v)){w->failed=true;return false;}n=snprintf(b,sizeof(b),"%.17g",v);return n>0&&(size_t)n<sizeof(b)&&append(w,b,(size_t)n);}

12
tui/src/json_writer.h Normal file
View file

@ -0,0 +1,12 @@
#ifndef TRAINLOG_JSON_WRITER_H
#define TRAINLOG_JSON_WRITER_H
#include <stdbool.h>
#include <stddef.h>
typedef struct TrainlogJsonWriter { char *data; size_t capacity; size_t size; bool failed; } TrainlogJsonWriter;
void trainlog_json_init(TrainlogJsonWriter *writer, char *data, size_t capacity);
bool trainlog_json_raw(TrainlogJsonWriter *writer, const char *value);
bool trainlog_json_string(TrainlogJsonWriter *writer, const char *value);
bool trainlog_json_size(TrainlogJsonWriter *writer, size_t value);
bool trainlog_json_i64(TrainlogJsonWriter *writer, long long value);
bool trainlog_json_double(TrainlogJsonWriter *writer, double value);
#endif

107
tui/src/web_dashboard.c Normal file
View file

@ -0,0 +1,107 @@
#include "trainlog/web_dashboard.h"
#include <math.h>
#include <pwd.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sqlite3.h>
#include "trainlog/body_zone_catalog.h"
#include "database_internal.h"
#include "timestamp.h"
#define SECONDS_PER_DAY INT64_C(86400)
static bool copy_column(sqlite3_stmt *s, int column, char *out, size_t cap) {
const unsigned char *text; int bytes;
if (sqlite3_column_type(s,column)!=SQLITE_TEXT) return false;
text=sqlite3_column_text(s,column); bytes=sqlite3_column_bytes(s,column);
if(text==NULL||bytes<0||(size_t)bytes>=cap)return false;
(void)memcpy(out,text,(size_t)bytes);out[bytes]='\0';return true;
}
static bool read_size(sqlite3_stmt*s,int c,size_t*out){sqlite3_int64 v;if(sqlite3_column_type(s,c)!=SQLITE_INTEGER)return false;v=sqlite3_column_int64(s,c);if(v<0||(uint64_t)v>(uint64_t)SIZE_MAX)return false;*out=(size_t)v;return true;}
static bool prepare(TrainlogDatabase*d,const char*sql,sqlite3_stmt**s){return sqlite3_prepare_v2(d->connection,sql,-1,s,NULL)==SQLITE_OK;}
static bool format_generated(int64_t now,char out[TRAINLOG_TIMESTAMP_MAX+1U]){
time_t value=(time_t)now;struct tm utc;if((int64_t)value!=now||gmtime_r(&value,&utc)==NULL)return false;
return strftime(out,TRAINLOG_TIMESTAMP_MAX+1U,"%Y-%m-%dT%H:%M:%SZ",&utc)>0U;
}
static bool format_local_date(time_t value,char out[11]){struct tm local;return localtime_r(&value,&local)!=NULL&&strftime(out,11U,"%Y-%m-%d",&local)==10U;}
static bool load_user(char out[TRAINLOG_NAME_MAX+1U]){
struct passwd value;struct passwd *result=NULL;char buffer[4096];int rc=getpwuid_r(geteuid(),&value,buffer,sizeof(buffer),&result);
if(rc!=0||result==NULL||result->pw_name==NULL||result->pw_name[0]=='\0'||strlen(result->pw_name)>TRAINLOG_NAME_MAX)return false;
(void)snprintf(out,TRAINLOG_NAME_MAX+1U,"%s",result->pw_name);return true;
}
static TrainlogStatus load_activity(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]=
"SELECT substr(s.started_at,1,10),COUNT(DISTINCT s.id),COUNT(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 unixepoch(s.started_at)<=?1 AND substr(s.started_at,1,10)>=?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)) GROUP BY substr(s.started_at,1,10) ORDER BY 1;";
sqlite3_stmt*s=NULL;int step;size_t i;time_t reference=(time_t)now;struct tm current;
if((int64_t)reference!=now)return TRAINLOG_STATUS_DATABASE_ERROR;
if(localtime_r(&reference,&current)==NULL)return TRAINLOG_STATUS_DATABASE_ERROR;
out->activity_count=TRAINLOG_WEB_ACTIVITY_DAYS;
for(i=0U;i<TRAINLOG_WEB_ACTIVITY_DAYS;++i){struct tm day=current;time_t normalized;day.tm_mday-=(int)(TRAINLOG_WEB_ACTIVITY_DAYS-1U-i);day.tm_isdst=-1;normalized=mktime(&day);if(normalized==(time_t)-1||!format_local_date(normalized,out->activity[i].date))return TRAINLOG_STATUS_DATABASE_ERROR;}
if(!prepare(d,SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK||sqlite3_bind_text(s,2,out->activity[0].date,-1,SQLITE_TRANSIENT)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){char date[11];size_t sessions,sets;if(!copy_column(s,0,date,sizeof(date))||!read_size(s,1,&sessions)||!read_size(s,2,&sets))goto fail;for(i=0U;i<out->activity_count;++i)if(strcmp(date,out->activity[i].date)==0){out->activity[i].active=true;out->activity[i].session_count=sessions;out->activity[i].set_count=sets;break;}}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_last(TrainlogDatabase*d,int64_t now,TrainlogWebLastSession*out){
static const char SESSION_SQL[]=
"SELECT s.session_id,s.started_at,COALESCE(s.ended_at,''),COUNT(DISTINCT CASE WHEN ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL THEN se.id END),COUNT(DISTINCT ps.id),COUNT(DISTINCT ca.id),COUNT(DISTINCT mr.session_exercise_row_id) FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE unixepoch(s.started_at)<=?1 AND (ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL) GROUP BY s.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC LIMIT 1;";
static const char ZONE_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 exercises e ON e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE s.session_id=?1 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)) GROUP BY z.zone_id ORDER BY z.zone_id;";
sqlite3_stmt*s=NULL;int step;TrainlogTimestampKey start,end;
if(!prepare(d,SESSION_SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK)goto fail;step=sqlite3_step(s);if(step==SQLITE_DONE){(void)sqlite3_finalize(s);return TRAINLOG_STATUS_OK;}if(step!=SQLITE_ROW||!copy_column(s,0,out->session_id,sizeof(out->session_id))||!copy_column(s,1,out->started_at,sizeof(out->started_at))||!copy_column(s,2,out->ended_at,sizeof(out->ended_at))||!read_size(s,3,&out->exercise_count)||!read_size(s,4,&out->set_count)||!read_size(s,5,&out->continuous_count)||!read_size(s,6,&out->max_count))goto fail;
out->available=true;out->has_ended_at=out->ended_at[0]!='\0';
if(!trainlog_timestamp_parse(out->started_at,strlen(out->started_at),&start)){out->available=false;goto invalid;}
if(out->has_ended_at){if(!trainlog_timestamp_parse(out->ended_at,strlen(out->ended_at),&end)||end.utc_second<start.utc_second)goto invalid;out->has_duration=true;out->duration_seconds=(uint64_t)(end.utc_second-start.utc_second);}
if(sqlite3_step(s)!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;s=NULL;
if(!prepare(d,ZONE_SQL,&s)||sqlite3_bind_text(s,1,out->session_id,-1,SQLITE_TRANSIENT)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebWorkedZone*z;const TrainlogBodyZone*catalog;if(out->zone_count==TRAINLOG_WEB_ZONE_CAPACITY){return TRAINLOG_STATUS_DATABASE_ERROR;}z=&out->zones[out->zone_count];if(!copy_column(s,0,z->zone_id,sizeof(z->zone_id))||!read_size(s,1,&z->occurrence_count)||!read_size(s,2,&z->set_count))goto fail;z->session_count=1U;catalog=trainlog_body_zone_catalog_lookup(z->zone_id);if(catalog==NULL||strlen(catalog->display_name)>TRAINLOG_NAME_MAX)goto invalid;(void)snprintf(z->label,sizeof(z->label),"%s",catalog->display_name);++out->zone_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
invalid:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_INVALID_ARGUMENT;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_maxima(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT s.session_id,se.entry_id,e.exercise_id,e.name,COALESCE(se.equipment_id,''),m.max_weight_kg,s.started_at FROM max_results m JOIN session_exercises se ON se.id=m.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE unixepoch(s.started_at)<=?1 ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC LIMIT ?2;";
sqlite3_stmt*s=NULL;int step;if(!prepare(d,SQL,&s)||sqlite3_bind_int64(s,1,now)!=SQLITE_OK||sqlite3_bind_int64(s,2,(sqlite3_int64)(TRAINLOG_WEB_MAX_RECORDS+1U))!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebMaxRecord*r;if(out->max_record_count==TRAINLOG_WEB_MAX_RECORDS){out->partial=true;continue;}r=&out->max_records[out->max_record_count];if(!copy_column(s,0,r->session_id,sizeof(r->session_id))||!copy_column(s,1,r->entry_id,sizeof(r->entry_id))||!copy_column(s,2,r->exercise_id,sizeof(r->exercise_id))||!copy_column(s,3,r->exercise_name,sizeof(r->exercise_name))||!copy_column(s,4,r->equipment_id,sizeof(r->equipment_id))||!copy_column(s,6,r->timestamp,sizeof(r->timestamp))||(sqlite3_column_type(s,5)!=SQLITE_FLOAT&&sqlite3_column_type(s,5)!=SQLITE_INTEGER)||!isfinite(sqlite3_column_double(s,5))||sqlite3_column_double(s,5)<=0.0){out->invalid_data=true;continue;}r->weight_kg=sqlite3_column_double(s,5);++out->max_record_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus load_zones(TrainlogDatabase*d,int64_t now,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT z.zone_id,COUNT(DISTINCT s.id),COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE substr(s.started_at,1,10)>=?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)) GROUP BY z.zone_id ORDER BY z.zone_id;";
sqlite3_stmt*s=NULL;int step;if(!prepare(d,SQL,&s)||sqlite3_bind_text(s,1,out->activity[TRAINLOG_WEB_ACTIVITY_DAYS-30U].date,-1,SQLITE_TRANSIENT)!=SQLITE_OK||sqlite3_bind_int64(s,2,now)!=SQLITE_OK)goto fail;
while((step=sqlite3_step(s))==SQLITE_ROW){TrainlogWebWorkedZone*z;const TrainlogBodyZone*catalog;if(out->muscle_zone_count==TRAINLOG_WEB_ZONE_CAPACITY){out->partial=true;continue;}z=&out->muscle_zones[out->muscle_zone_count];if(!copy_column(s,0,z->zone_id,sizeof(z->zone_id))||!read_size(s,1,&z->session_count)||!read_size(s,2,&z->occurrence_count)||!read_size(s,3,&z->set_count))goto fail;catalog=trainlog_body_zone_catalog_lookup(z->zone_id);if(catalog==NULL||strlen(catalog->display_name)>TRAINLOG_NAME_MAX){out->invalid_data=true;continue;}(void)snprintf(z->label,sizeof(z->label),"%s",catalog->display_name);++out->muscle_zone_count;}
if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;
fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus scan_quality(TrainlogDatabase*d,TrainlogWebDashboardSnapshot*out){
static const char SQL[]="SELECT s.started_at,COALESCE(s.ended_at,'') FROM sessions s WHERE 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) ORDER BY s.id LIMIT 4097;";
sqlite3_stmt*s=NULL;int step;size_t count=0U;if(!prepare(d,SQL,&s))goto fail;while((step=sqlite3_step(s))==SQLITE_ROW){char started[TRAINLOG_TIMESTAMP_MAX+1U],ended[TRAINLOG_TIMESTAMP_MAX+1U];TrainlogTimestampKey start_key,end_key;++count;if(count>4096U){out->partial=true;continue;}if(!copy_column(s,0,started,sizeof(started))||!copy_column(s,1,ended,sizeof(ended))||!trainlog_timestamp_parse(started,strlen(started),&start_key)||(ended[0]!='\0'&&(!trainlog_timestamp_parse(ended,strlen(ended),&end_key)||trainlog_timestamp_compare(&end_key,&start_key)<0)))out->invalid_data=true;}if(step!=SQLITE_DONE||sqlite3_finalize(s)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;return TRAINLOG_STATUS_OK;fail:if(s!=NULL)(void)sqlite3_finalize(s);return TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_web_dashboard_load(TrainlogDatabase*d,const TrainlogWebDashboardQuery*q,TrainlogWebDashboardSnapshot*out){
TrainlogStatus status,end_status;TrainlogDashboardQuery progression_query;
if(d==NULL||q==NULL||out==NULL)return TRAINLOG_STATUS_INVALID_ARGUMENT;(void)memset(out,0,sizeof(*out));
out->next_session_reason="no_persisted_executable_plan";out->cardio_reason="no_cardio_data_source";
if(!format_generated(q->reference_unix_second,out->generated_at)||!load_user(out->user_display_name))return TRAINLOG_STATUS_DATABASE_ERROR;
status=trainlog_database_read_snapshot_begin(d);if(status!=TRAINLOG_STATUS_OK)return status;
status=load_activity(d,q->reference_unix_second,out);
if(status==TRAINLOG_STATUS_OK)status=scan_quality(d,out);
if(status==TRAINLOG_STATUS_OK){progression_query.period=TRAINLOG_DASHBOARD_90_DAYS;progression_query.reference_unix_second=q->reference_unix_second;status=trainlog_dashboard_load(d,&progression_query,&out->progression);out->partial=out->partial||out->progression.partial;out->invalid_data=out->invalid_data||out->progression.invalid_data;}
if(status==TRAINLOG_STATUS_OK){status=load_last(d,q->reference_unix_second,&out->last_session);if(status==TRAINLOG_STATUS_INVALID_ARGUMENT){out->invalid_data=true;status=TRAINLOG_STATUS_OK;}}
if(status==TRAINLOG_STATUS_OK)status=load_maxima(d,q->reference_unix_second,out);
if(status==TRAINLOG_STATUS_OK)status=load_zones(d,q->reference_unix_second,out);
end_status=trainlog_database_read_snapshot_end(d,status==TRAINLOG_STATUS_OK);if(end_status!=TRAINLOG_STATUS_OK)status=end_status;return status;
}

View file

@ -0,0 +1,28 @@
#include "trainlog/web_dashboard.h"
#include "json_writer.h"
#include <string.h>
#define RAW(value) do { if (!trainlog_json_raw(&w,(value))) goto overflow; } while (0)
#define STR(value) do { if (!trainlog_json_string(&w,(value))) goto overflow; } while (0)
#define NUM(value) do { if (!trainlog_json_size(&w,(value))) goto overflow; } while (0)
static const char *tracking_name(TrainlogTrackingMode value){return value==TRAINLOG_TRACKING_DURATION?"duration":"reps";}
static const char *load_name(TrainlogLoadMode value){if(value==TRAINLOG_LOAD_EXTERNAL)return "external";if(value==TRAINLOG_LOAD_ASSISTANCE)return "assistance";return "none";}
static bool point_improved(const TrainlogDashboardSnapshot*p,size_t i){const TrainlogExercisePerformancePoint*a,*b;if(i==0U)return false;a=&p->performance[i-1U];b=&p->performance[i];if(!a->has_weight&&!b->has_weight)return b->metric_value>a->metric_value;if(a->has_weight!=b->has_weight)return false;if(p->performance_load_mode==TRAINLOG_LOAD_ASSISTANCE)return b->weight_kg<a->weight_kg||(b->weight_kg==a->weight_kg&&b->metric_value>a->metric_value);return b->weight_kg>a->weight_kg||(b->weight_kg==a->weight_kg&&b->metric_value>a->metric_value);}
static bool write_zone(TrainlogJsonWriter*w0,const TrainlogWebWorkedZone*z){TrainlogJsonWriter w=*w0;RAW("{\"zone_id\":");STR(z->zone_id);RAW(",\"label\":");STR(z->label);RAW(",\"session_count\":");NUM(z->session_count);RAW(",\"occurrence_count\":");NUM(z->occurrence_count);RAW(",\"set_count\":");NUM(z->set_count);RAW("}");*w0=w;return true;overflow:*w0=w;return false;}
TrainlogStatus trainlog_web_dashboard_serialize(const TrainlogWebDashboardSnapshot*s,char*out,size_t cap,size_t*out_size){
TrainlogJsonWriter w;size_t i;if(s==NULL||out==NULL||out_size==NULL)return TRAINLOG_STATUS_INVALID_ARGUMENT;*out_size=0U;trainlog_json_init(&w,out,cap);
RAW("{\"api_version\":1,\"data\":{\"footer\":{\"user\":");STR(s->user_display_name);RAW(",\"last_session_date\":");if(s->last_session.available){char date[11];(void)memcpy(date,s->last_session.started_at,10U);date[10]='\0';STR(date);}else RAW("null");RAW(",\"last_zones\":[");for(i=0U;i<s->last_session.zone_count;++i){if(i>0U)RAW(",");STR(s->last_session.zones[i].label);}RAW("]},");
RAW("\"next_session\":{\"available\":false,\"reason\":");STR(s->next_session_reason);RAW("},");
RAW("\"activity\":{\"available\":true,\"window_days\":90,\"days\":[");for(i=0U;i<s->activity_count;++i){const TrainlogWebActivityDay*d=&s->activity[i];if(i>0U)RAW(",");RAW("{\"date\":");STR(d->date);RAW(",\"active\":");RAW(d->active?"true":"false");RAW(",\"session_count\":");NUM(d->session_count);RAW(",\"set_count\":");NUM(d->set_count);RAW("}");}RAW("]},");
RAW("\"progression\":{");if(!s->progression.has_performance){RAW("\"available\":false,\"reason\":\"no_comparable_performance\"");}else{RAW("\"available\":true,\"identity\":{\"exercise_id\":");STR(s->progression.exercise.exercise_id);RAW(",\"exercise_name\":");STR(s->progression.exercise.name);RAW(",\"equipment_id\":");STR(s->progression.performance_equipment_id);RAW(",\"equipment_label\":");STR(s->progression.performance_equipment_label);RAW(",\"tracking_mode\":");STR(tracking_name(s->progression.performance_tracking_mode));RAW(",\"load_mode\":");STR(load_name(s->progression.performance_load_mode));RAW(",\"dose\":");if(!trainlog_json_i64(&w,(long long)s->progression.performance_dose))goto overflow;RAW("},\"points\":[");for(i=0U;i<s->progression.performance_count;++i){const TrainlogExercisePerformancePoint*p=&s->progression.performance[i];if(i>0U)RAW(",");RAW("{\"session_id\":");STR(p->session_id);RAW(",\"timestamp\":");STR(p->started_at);RAW(",\"metric_value\":");if(!trainlog_json_i64(&w,(long long)p->metric_value))goto overflow;RAW(",\"weight_kg\":");if(p->has_weight){if(!trainlog_json_double(&w,p->weight_kg))goto overflow;}else RAW("null");RAW(",\"improved\":");RAW(point_improved(&s->progression,i)?"true":"false");RAW("}");}RAW("]");}RAW("},");
RAW("\"last_session\":{");if(!s->last_session.available){RAW("\"available\":false,\"reason\":\"no_observable_session\"");}else{const TrainlogWebLastSession*l=&s->last_session;RAW("\"available\":true,\"session_id\":");STR(l->session_id);RAW(",\"started_at\":");STR(l->started_at);RAW(",\"ended_at\":");if(l->has_ended_at)STR(l->ended_at);else RAW("null");RAW(",\"duration_seconds\":");if(l->has_duration){if(!trainlog_json_i64(&w,(long long)l->duration_seconds))goto overflow;}else RAW("null");RAW(",\"exercise_count\":");NUM(l->exercise_count);RAW(",\"set_count\":");NUM(l->set_count);RAW(",\"continuous_count\":");NUM(l->continuous_count);RAW(",\"max_count\":");NUM(l->max_count);RAW(",\"primary_zones\":[");for(i=0U;i<l->zone_count;++i){if(i>0U)RAW(",");if(!write_zone(&w,&l->zones[i]))goto overflow;}RAW("]");}RAW("},");
RAW("\"max_records\":{\"available\":");RAW(s->max_record_count>0U?"true":"false");if(s->max_record_count==0U)RAW(",\"reason\":\"no_explicit_max_results\"");RAW(",\"records\":[");for(i=0U;i<s->max_record_count;++i){const TrainlogWebMaxRecord*r=&s->max_records[i];if(i>0U)RAW(",");RAW("{\"session_id\":");STR(r->session_id);RAW(",\"entry_id\":");STR(r->entry_id);RAW(",\"exercise_id\":");STR(r->exercise_id);RAW(",\"exercise_name\":");STR(r->exercise_name);RAW(",\"equipment_id\":");STR(r->equipment_id);RAW(",\"weight_kg\":");if(!trainlog_json_double(&w,r->weight_kg))goto overflow;RAW(",\"timestamp\":");STR(r->timestamp);RAW("}");}RAW("]},");
RAW("\"muscle_distribution\":{\"available\":");RAW(s->muscle_zone_count>0U?"true":"false");if(s->muscle_zone_count==0U)RAW(",\"reason\":\"no_worked_primary_zones\"");RAW(",\"window_days\":30,\"primary_zones\":[");for(i=0U;i<s->muscle_zone_count;++i){if(i>0U)RAW(",");if(!write_zone(&w,&s->muscle_zones[i]))goto overflow;}RAW("]},");
RAW("\"cardio\":{\"available\":false,\"reason\":");STR(s->cardio_reason);RAW("}},\"meta\":{\"partial\":");RAW(s->partial?"true":"false");RAW(",\"invalid_data\":");RAW(s->invalid_data?"true":"false");RAW(",\"generated_at\":");STR(s->generated_at);RAW("}}\n");
if(w.failed)goto overflow;*out_size=w.size;return TRAINLOG_STATUS_OK;
overflow:if(cap>0U)out[0]='\0';return TRAINLOG_STATUS_SYSTEM_ERROR;
}

View file

@ -9,7 +9,9 @@
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <time.h>
#include "trainlog/web_dashboard.h"
#include "web_assets.h"
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
@ -23,6 +25,7 @@
typedef struct TrainlogWebContext {
uint16_t port;
TrainlogDatabase *database;
} TrainlogWebContext;
typedef struct TrainlogHttpRequestState {
@ -42,7 +45,7 @@ static enum MHD_Result queue_json(struct MHD_Connection *connection,
struct MHD_Response *response;
enum MHD_Result result;
response = MHD_create_response_from_buffer(strlen(body), (void *)body,
MHD_RESPMEM_PERSISTENT);
MHD_RESPMEM_MUST_COPY);
if (response == NULL) return MHD_NO;
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
"application/json; charset=utf-8") != MHD_YES ||
@ -191,6 +194,28 @@ static enum MHD_Result handle_request(void *closure,
"{\"error\":\"method_not_allowed\"}\n", "GET");
return queue_json(connection, MHD_HTTP_OK, HEALTH, NULL);
}
if (strcmp(url, "/api/v1/dashboard") == 0) {
TrainlogWebDashboardQuery query;
TrainlogWebDashboardSnapshot snapshot;
char json[TRAINLOG_WEB_JSON_CAPACITY];
size_t json_size;
time_t now;
if (!is_get)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "GET");
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;
if (trainlog_web_dashboard_load(context->database, &query,
&snapshot) != TRAINLOG_STATUS_OK ||
trainlog_web_dashboard_serialize(&snapshot, json, sizeof(json),
&json_size) != TRAINLOG_STATUS_OK || json_size == 0U)
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"dashboard_unavailable\"}\n", NULL);
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
/* INVARIANT: API paths never fall through to the SPA index. */
if (strncmp(url, "/api/", 5U) == 0)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
@ -250,6 +275,7 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
bind_address.sin_port = htons(port);
bind_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
context.port = port;
context.database = database;
errno = 0;
daemon = MHD_start_daemon(MHD_USE_ERROR_LOG | MHD_USE_NO_THREAD_SAFETY,
0U, accept_loopback_only, NULL, handle_request, &context,

View file

@ -0,0 +1,39 @@
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/web_dashboard.h"
#define CHECK(x) do { if (!(x)) { (void)fprintf(stderr,"CHECK failed %s:%d: %s\n",__FILE__,__LINE__,#x); return false; } } while (0)
static bool insert_fact(TrainlogDatabase*d,const char*sid,const char*eid,const char*when,int kind){
TrainlogSessionExerciseInput occurrence;TrainlogSessionInput session;TrainlogSetInput set={8,0,true,42.0};
(void)memset(&occurrence,0,sizeof(occurrence));(void)memset(&session,0,sizeof(session));
(void)snprintf(occurrence.entry_id,sizeof(occurrence.entry_id),"%s",eid);
(void)snprintf(occurrence.exercise_id,sizeof(occurrence.exercise_id),"%s",kind==1?"ex_90000000-0000-4000-8000-000000000002":"ex_90000000-0000-4000-8000-000000000001");
occurrence.tracking_mode=kind==1?TRAINLOG_TRACKING_DURATION:TRAINLOG_TRACKING_REPS;occurrence.recording_mode=kind==1?TRAINLOG_RECORDING_CONTINUOUS:TRAINLOG_RECORDING_SETS;occurrence.load_mode=kind==1?TRAINLOG_LOAD_NONE:TRAINLOG_LOAD_EXTERNAL;
if(kind==0){occurrence.sets=&set;occurrence.set_count=1U;}else if(kind==1){occurrence.continuous_duration_seconds=600;}else{occurrence.has_max_weight=true;occurrence.max_weight_kg=100.0+(double)kind;}
(void)snprintf(session.session_id,sizeof(session.session_id),"%s",sid);(void)snprintf(session.started_at,sizeof(session.started_at),"%s",when);session.session_type=kind>=2?TRAINLOG_SESSION_MAX_TEST:TRAINLOG_SESSION_TRAINING;session.exercises=&occurrence;session.exercise_count=1U;
return trainlog_database_insert_session(d,&session)==TRAINLOG_STATUS_OK;
}
static bool test_empty_and_real_facts(void){
TrainlogDatabase*d=NULL;TrainlogWebDashboardQuery q={INT64_C(1789560000)};TrainlogWebDashboardSnapshot s;const char*none[1]={NULL};size_t i;char id[64],entry[64];
CHECK(trainlog_database_open(":memory:",&d)==TRAINLOG_STATUS_OK);CHECK(trainlog_web_dashboard_load(d,&q,&s)==TRAINLOG_STATUS_OK);
CHECK(!s.partial&&!s.invalid_data&&!s.next_session_available&&!s.cardio_available);CHECK(strcmp(s.next_session_reason,"no_persisted_executable_plan")==0);CHECK(s.activity_count==90U&&!s.last_session.available&&s.muscle_zone_count==0U);
CHECK(trainlog_database_insert_exercise_profiled(d,"ex_90000000-0000-4000-8000-000000000001","Échappé \"dos\"","echappe dos",TRAINLOG_TRACKING_REPS,TRAINLOG_RECORDING_SETS,(TrainlogExerciseDataFields)0)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(d,"ex_90000000-0000-4000-8000-000000000001","back",none,0U)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(d,"ex_90000000-0000-4000-8000-000000000002","Continu","continu",TRAINLOG_TRACKING_DURATION,TRAINLOG_RECORDING_CONTINUOUS,(TrainlogExerciseDataFields)0)==TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(d,"ex_90000000-0000-4000-8000-000000000002","back",none,0U)==TRAINLOG_STATUS_OK);
CHECK(insert_fact(d,"se_90000000-0000-4000-8000-000000000001","en_90000000-0000-4000-8000-000000000001","2026-09-15T10:00:00+02:00",0));
CHECK(insert_fact(d,"se_90000000-0000-4000-8000-000000000002","en_90000000-0000-4000-8000-000000000002","2026-09-15T18:00:00+02:00",1));
for(i=0U;i<9U;++i){(void)snprintf(id,sizeof(id),"se_91000000-0000-4000-8000-%012zu",i);(void)snprintf(entry,sizeof(entry),"en_91000000-0000-4000-8000-%012zu",i);CHECK(insert_fact(d,id,entry,"2026-09-14T12:00:00+02:00",(int)i+2));}
CHECK(trainlog_web_dashboard_load(d,&q,&s)==TRAINLOG_STATUS_OK);CHECK(s.partial);CHECK(s.last_session.available);CHECK(strcmp(s.last_session.session_id,"se_90000000-0000-4000-8000-000000000002")==0);CHECK(!s.last_session.has_ended_at&&!s.last_session.has_duration);CHECK(s.last_session.continuous_count==1U&&s.last_session.zone_count==1U);CHECK(s.max_record_count==8U&&s.muscle_zone_count==1U);CHECK(s.muscle_zones[0].session_count==11U&&s.muscle_zones[0].occurrence_count==11U&&s.muscle_zones[0].set_count==1U);
for(i=0U;i<s.activity_count;++i)if(strcmp(s.activity[i].date,"2026-09-15")==0)CHECK(s.activity[i].active&&s.activity[i].session_count==2U&&s.activity[i].set_count==1U);
trainlog_database_close(d);return true;
}
static bool test_json_escaping_and_limit(void){TrainlogWebDashboardSnapshot s;char json[TRAINLOG_WEB_JSON_CAPACITY];char tiny[8];size_t size;(void)memset(&s,0,sizeof(s));(void)snprintf(s.user_display_name,sizeof(s.user_display_name),"A\"B\\C\nÉ");(void)snprintf(s.generated_at,sizeof(s.generated_at),"2026-09-16T12:00:00Z");s.next_session_reason="no_persisted_executable_plan";s.cardio_reason="no_cardio_data_source";CHECK(trainlog_web_dashboard_serialize(&s,json,sizeof(json),&size)==TRAINLOG_STATUS_OK&&size>0U);CHECK(strstr(json,"A\\\"B\\\\C\\nÉ")!=NULL);CHECK(trainlog_web_dashboard_serialize(&s,tiny,sizeof(tiny),&size)==TRAINLOG_STATUS_SYSTEM_ERROR);s.user_display_name[0]=(char)0xc3;s.user_display_name[1]='\0';CHECK(trainlog_web_dashboard_serialize(&s,json,sizeof(json),&size)==TRAINLOG_STATUS_SYSTEM_ERROR);return true;}
int main(void){return test_empty_and_real_facts()&&test_json_escaping_and_limit()?0:1;}

View file

@ -2,9 +2,12 @@
#include "trainlog/cli.h"
#include "trainlog/paths.h"
#include "trainlog/web_server.h"
#include "trainlog/web_dashboard.h"
int main(void)
{
TrainlogCliOptions options = {TRAINLOG_RUN_TUI, 8080U};
return options.mode == TRAINLOG_RUN_TUI && options.port == 8080U ? 0 : 1;
TrainlogWebDashboardQuery query = {0};
return options.mode == TRAINLOG_RUN_TUI && options.port == 8080U &&
query.reference_unix_second == 0 ? 0 : 1;
}

View file

@ -156,6 +156,19 @@ static bool test_http_contract(TrainlogDatabase *database)
CHECK(strstr(response,
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"0.1.2\"}") != NULL);
CHECK(exchange(port,
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: application/json; charset=utf-8") != NULL);
CHECK(strstr(response, "\"api_version\":1") != NULL);
CHECK(strstr(response, "\"no_persisted_executable_plan\"") != NULL);
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
CHECK(strstr(response, "\"window_days\":90") != NULL);
CHECK(strstr(response, "\"partial\":false") != NULL);
CHECK(exchange(port,
"POST /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL &&
strstr(response, "Allow: GET") != NULL);
CHECK(exchange(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: text/html; charset=utf-8") != NULL);

58
web/src/api/dashboard.ts Normal file
View file

@ -0,0 +1,58 @@
export interface DashboardFooter {
user: string
last_session_date: string | null
last_zones: string[]
}
export interface DashboardSnapshot {
api_version: 1
data: {
footer: DashboardFooter
next_session: { available: false; reason: string }
cardio: { available: false; reason: string }
activity: { available: true; window_days: 90; days: unknown[] }
progression: { available: boolean; reason?: string }
last_session: { available: boolean; reason?: string }
max_records: { available: boolean; records: unknown[] }
muscle_distribution: { available: boolean; window_days: 30; primary_zones: unknown[] }
}
meta: { partial: boolean; invalid_data: boolean; generated_at: string }
}
const record = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const nonNegativeInteger = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
const workedZone = (value: unknown) => record(value) &&
typeof value.zone_id === 'string' && typeof value.label === 'string' &&
nonNegativeInteger(value.session_count) && nonNegativeInteger(value.occurrence_count) &&
nonNegativeInteger(value.set_count)
export function parseDashboard(value: unknown): DashboardSnapshot {
if (!record(value) || value.api_version !== 1 || !record(value.data) || !record(value.meta)) throw new TypeError('dashboard invalide')
const data = value.data
const footer = data.footer
const next = data.next_session
const cardio = data.cardio
const activity = data.activity
const progression = data.progression
const last = data.last_session
const maxima = data.max_records
const muscles = data.muscle_distribution
if (!record(footer) || typeof footer.user !== 'string' || !(footer.last_session_date === null || typeof footer.last_session_date === 'string') || !Array.isArray(footer.last_zones) || !footer.last_zones.every((zone) => typeof zone === 'string')) throw new TypeError('footer invalide')
if (!record(next) || next.available !== false || typeof next.reason !== 'string') throw new TypeError('prochaine séance invalide')
if (!record(cardio) || cardio.available !== false || typeof cardio.reason !== 'string') throw new TypeError('cardio invalide')
if (!record(activity) || activity.available !== true || activity.window_days !== 90 || !Array.isArray(activity.days) || activity.days.length !== 90 || !activity.days.every((day) => record(day) && typeof day.date === 'string' && typeof day.active === 'boolean' && nonNegativeInteger(day.session_count) && nonNegativeInteger(day.set_count))) throw new TypeError('activité invalide')
if (!record(progression) || typeof progression.available !== 'boolean' || (progression.available === false && typeof progression.reason !== 'string') || (progression.available === true && (!record(progression.identity) || !Array.isArray(progression.points)))) throw new TypeError('progression invalide')
if (!record(last) || typeof last.available !== 'boolean' || (last.available === false && typeof last.reason !== 'string') || (last.available === true && (typeof last.session_id !== 'string' || typeof last.started_at !== 'string' || !Array.isArray(last.primary_zones) || !last.primary_zones.every(workedZone)))) throw new TypeError('dernière séance invalide')
if (!record(maxima) || typeof maxima.available !== 'boolean' || !Array.isArray(maxima.records) || !maxima.records.every((item) => record(item) && typeof item.session_id === 'string' && typeof item.entry_id === 'string' && typeof item.exercise_id === 'string' && typeof item.exercise_name === 'string' && typeof item.equipment_id === 'string' && typeof item.weight_kg === 'number' && Number.isFinite(item.weight_kg) && typeof item.timestamp === 'string')) throw new TypeError('MAX invalide')
if (!record(muscles) || typeof muscles.available !== 'boolean' || muscles.window_days !== 30 || !Array.isArray(muscles.primary_zones) || !muscles.primary_zones.every(workedZone)) throw new TypeError('zones invalides')
if (typeof value.meta.partial !== 'boolean' || typeof value.meta.invalid_data !== 'boolean' || typeof value.meta.generated_at !== 'string') throw new TypeError('meta invalide')
return value as unknown as DashboardSnapshot
}
export async function fetchDashboard(signal?: AbortSignal): Promise<DashboardSnapshot> {
const response = await fetch('/api/v1/dashboard', { headers: { Accept: 'application/json' }, signal })
if (!response.ok) throw new Error(`dashboard HTTP ${response.status}`)
return parseDashboard(await response.json())
}

View file

@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { App } from './App'
import { parseDashboard, type DashboardSnapshot } from '../api/dashboard'
const health = {
api_version: 1,
@ -9,11 +10,26 @@ const health = {
version: '0.1.2',
}
const dashboard: DashboardSnapshot = {
api_version: 1,
data: {
footer: { user: '—', last_session_date: null, last_zones: [] },
next_session: { available: false, reason: 'no_persisted_executable_plan' },
activity: { available: true, window_days: 90, days: Array.from({ length: 90 }, (_, day) => ({ date: `day-${day}`, active: false, session_count: 0, set_count: 0 })) },
progression: { available: false, reason: 'no_comparable_performance' },
last_session: { available: false, reason: 'no_observable_session' },
max_records: { available: false, records: [] },
muscle_distribution: { available: false, window_days: 30, primary_zones: [] },
cardio: { available: false, reason: 'no_cardio_data_source' },
},
meta: { partial: false, invalid_data: false, generated_at: '2026-09-16T12:00:00Z' },
}
function mockHealth(value: unknown = health) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({
ok: true,
json: () => Promise.resolve(value),
}))
json: () => Promise.resolve(String(input).includes('/dashboard') ? dashboard : value),
})))
}
describe('shell Trainlog', () => {
@ -77,6 +93,32 @@ describe('shell Trainlog', () => {
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend indisponible'))
})
it('alimente le Footer uniquement avec les faits Dashboard reçus', async () => {
const actual = structuredClone(dashboard)
actual.data.footer = { user: 'fy59', last_session_date: '2026-09-15T10:00:00+02:00', last_zones: ['DOS', 'BRAS'] }
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({
ok: true,
json: () => Promise.resolve(String(input).includes('/dashboard') ? actual : health),
})))
render(<App />)
await waitFor(() => expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('fy59'))
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('2026-09-15T10:00:00+02:00')
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('DOS · BRAS')
})
it('rejette les snapshots Dashboard invalides sans inventer de Footer', async () => {
expect(() => parseDashboard({ api_version: 1, data: {}, meta: {} })).toThrow()
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({
ok: true,
json: () => Promise.resolve(String(input).includes('/dashboard') ? { status: 'ok' } : health),
})))
render(<App />)
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend connecté'))
expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—')
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible')
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible')
})
it('expose les cinq destinations dans une navigation clavier sémantique', () => {
mockHealth()
render(<App />)

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { fetchHealth, type Health } from '../api/health'
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import { DashboardPage } from '../routes/DashboardPage'
@ -19,6 +20,7 @@ export function App() {
const [health, setHealth] = useState<Health | null>(null)
const [healthPending, setHealthPending] = useState(true)
const [healthFailed, setHealthFailed] = useState(false)
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
useEffect(() => {
const controller = new AbortController()
@ -26,6 +28,7 @@ export function App() {
setHealth(value)
setHealthFailed(false)
}).catch(() => setHealthFailed(true)).finally(() => setHealthPending(false))
fetchDashboard(controller.signal).then(setDashboard).catch(() => setDashboard(null))
return () => controller.abort()
}, [])
@ -39,7 +42,7 @@ export function App() {
<a className="skip-link" href="#main-content">Aller au contenu</a>
<Header activeRoute={route} onNavigate={navigate} />
<main className="app-main" id="main-content">{content}</main>
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} />
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
</div>
)
}

View file

@ -1,12 +1,14 @@
import type { Health } from '../api/health'
import type { DashboardSnapshot } from '../api/dashboard'
interface FooterProps {
health: Health | null
healthPending: boolean
healthFailed: boolean
dashboard: DashboardSnapshot | null
}
export function Footer({ health, healthPending, healthFailed }: FooterProps) {
export function Footer({ health, healthPending, healthFailed, dashboard }: FooterProps) {
const backendLabel = healthPending ? 'Connexion en cours' :
healthFailed ? 'Backend indisponible' : `Backend connecté · v${health?.version ?? '—'}`
@ -14,15 +16,15 @@ export function Footer({ health, healthPending, healthFailed }: FooterProps) {
<footer className="status-footer">
<section className="status-block status-user" aria-label="Utilisateur">
<span className="status-label">UTILISATEUR</span>
<span className="status-value">—</span>
<span className="status-value">{dashboard?.data.footer.user || '—'}</span>
</section>
<section className="status-block status-session" aria-label="Dernière séance">
<span className="status-label">DERNIÈRE SÉANCE</span>
<span className="status-value">Indisponible</span>
<span className="status-value">{dashboard?.data.footer.last_session_date ?? 'Indisponible'}</span>
</section>
<section className="status-block status-zone" aria-label="Dernière zone">
<span className="status-label">DERNIÈRE ZONE</span>
<span className="status-value">Indisponible</span>
<span className="status-value">{dashboard?.data.footer.last_zones.length ? dashboard.data.footer.last_zones.join(' · ') : 'Indisponible'}</span>
</section>
<span className={`backend-state${healthFailed ? ' is-error' : ''}`} role="status">
<span className="backend-dot" aria-hidden="true" />{backendLabel}