diff --git a/docs/architecture.md b/docs/architecture.md index e2237cf..4486d25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index ce65585..bb63c7d 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index e9d0dc5..71934ec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/docs/tests.md b/docs/tests.md index ee7c8da..741cc87 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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: diff --git a/tui/include/trainlog/web_dashboard.h b/tui/include/trainlog/web_dashboard.h new file mode 100644 index 0000000..ce1cc54 --- /dev/null +++ b/tui/include/trainlog/web_dashboard.h @@ -0,0 +1,97 @@ +/* Trainlog Web Dashboard V1 typed read model. */ +#ifndef TRAINLOG_WEB_DASHBOARD_H +#define TRAINLOG_WEB_DASHBOARD_H + +#include +#include +#include + +#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 diff --git a/tui/meson.build b/tui/meson.build index d9b36f8..fa569b9 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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', diff --git a/tui/src/json_writer.c b/tui/src/json_writer.c new file mode 100644 index 0000000..761c01e --- /dev/null +++ b/tui/src/json_writer.c @@ -0,0 +1,37 @@ +#include "json_writer.h" +#include +#include +#include +#include + +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)n0&&(size_t)nfailed=true;return false;}n=snprintf(b,sizeof(b),"%.17g",v);return n>0&&(size_t)n +#include +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 diff --git a/tui/src/web_dashboard.c b/tui/src/web_dashboard.c new file mode 100644 index 0000000..04dde37 --- /dev/null +++ b/tui/src/web_dashboard.c @@ -0,0 +1,107 @@ +#include "trainlog/web_dashboard.h" + +#include +#include +#include +#include +#include +#include + +#include + +#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;iactivity[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;iactivity_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_secondhas_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; +} diff --git a/tui/src/web_dashboard_json.c b/tui/src/web_dashboard_json.c new file mode 100644 index 0000000..fd6ac33 --- /dev/null +++ b/tui/src/web_dashboard_json.c @@ -0,0 +1,28 @@ +#include "trainlog/web_dashboard.h" +#include "json_writer.h" + +#include + +#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_kgweight_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;ilast_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;iactivity_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;iprogression.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;izone_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;imax_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;imuscle_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; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 0f6325d..62b84c7 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -9,7 +9,9 @@ #include #include #include +#include +#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, diff --git a/tui/tests/test_web_dashboard.c b/tui/tests/test_web_dashboard.c new file mode 100644 index 0000000..bfd6b1a --- /dev/null +++ b/tui/tests/test_web_dashboard.c @@ -0,0 +1,39 @@ +#include +#include +#include +#include + +#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;i0U);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;} diff --git a/tui/tests/test_web_headers.c b/tui/tests/test_web_headers.c index a3a220f..7def7da 100644 --- a/tui/tests/test_web_headers.c +++ b/tui/tests/test_web_headers.c @@ -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; } diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 6b3d3e7..9b4be8c 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -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); diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts new file mode 100644 index 0000000..c6db49c --- /dev/null +++ b/web/src/api/dashboard.ts @@ -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 => + 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 { + 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()) +} diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx index 83ddf83..b44ba89 100644 --- a/web/src/app/App.test.tsx +++ b/web/src/app/App.test.tsx @@ -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() + 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() + 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() diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index ff6cdf3..6379d44 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -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(null) const [healthPending, setHealthPending] = useState(true) const [healthFailed, setHealthFailed] = useState(false) + const [dashboard, setDashboard] = useState(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() { Aller au contenu
{content}
-