feat(web): freeze dashboard data contract
This commit is contained in:
parent
6443221327
commit
f894f744f3
18 changed files with 569 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
97
tui/include/trainlog/web_dashboard.h
Normal file
97
tui/include/trainlog/web_dashboard.h
Normal 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
|
||||
|
|
@ -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
37
tui/src/json_writer.c
Normal 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
12
tui/src/json_writer.h
Normal 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
107
tui/src/web_dashboard.c
Normal 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,¤t)==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;
|
||||
}
|
||||
28
tui/src/web_dashboard_json.c
Normal file
28
tui/src/web_dashboard_json.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
39
tui/tests/test_web_dashboard.c
Normal file
39
tui/tests/test_web_dashboard.c
Normal 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;}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
58
web/src/api/dashboard.ts
Normal 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())
|
||||
}
|
||||
|
|
@ -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 />)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue