feat(web): persist dashboard layout

This commit is contained in:
fy59 2026-09-16 14:19:23 +02:00
parent 3973a60233
commit 799c2e0a52
20 changed files with 1181 additions and 65 deletions

View file

@ -0,0 +1,15 @@
{
"format": "trainlog-dashboard-layout-contract",
"version": 1,
"columns": 12,
"max_y": 200,
"tiles": [
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
]
}

View file

@ -302,6 +302,13 @@ 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.
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` implements this path as a strict private
0600 JSON preference, atomically replaced without SQLite involvement. GET/PUT/
DELETE `/api/v1/dashboard-layout` expose the canonical Trainlog shape. ETag and
If-Match guard optimistic revisions; mutation additionally requires exact
loopback or `trainlog.perf` Origin and an ephemeral startup-random CSRF header.
Invalid files are preserved for diagnosis while GET safely returns the default.
`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:

View file

@ -43,6 +43,7 @@ parallel implementation of its rules.
| Web CLI/HTTP infrastructure | `WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` |
| Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` |
| Web Dashboard grid | `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` |
| Web Dashboard layout | `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
@ -195,9 +196,15 @@ and 12 Vitest tests for parsing and factual Footer integration.
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` uses `react-grid-layout` 2.2.4 behind an
independent Trainlog model/validator. Its 12-column desktop layout has seven
stable tile IDs, bounded per-tile dimensions, vertical collision compaction,
explicit edit/cancel/reset/session-only-save behavior, mouse drag/two-axis
explicit edit/cancel/reset behavior, mouse drag/two-axis
resize, an announced keyboard alternative, and derived six/one-column
responsive projections. No layout is persisted yet.
responsive projections. Persistence is supplied by the following frozen slice.
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` adds a strict versioned seven-tile file
under the private XDG configuration directory, backend revalidation, atomic
0600 replacement, ETag/If-Match conflict control, Origin plus ephemeral CSRF
protection, and durable React load/save/reset behavior. Only the desktop
12-column canon persists.
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus
@ -214,9 +221,9 @@ smoke test are not automated. The latter is why
- `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.
- `WEB_DASHBOARD_GRID_V1=PASS/FROZEN`: layout changes live only in current
React memory. Versioned disk/API persistence is the next tranche,
`WEB_DASHBOARD_LAYOUT_V1`.
- `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN`: versioned disk/API persistence is
complete. The seven tile bodies remain placeholders for
`WEB_DASHBOARD_TILES_V1`.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test.

View file

@ -0,0 +1,79 @@
# Web Dashboard layout V1
Status: `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN`
## File contract
The canonical UI preference is
`$XDG_CONFIG_HOME/trainlog/web/dashboard-layout-v1.json`, falling back to
`$HOME/.config/trainlog/web/dashboard-layout-v1.json`. Trainlog-owned
directories are mode 0700 and the regular file is mode 0600. It is not SQLite,
training data, an exchange format or synchronization input.
The strict document has exactly `format`, `version`, `revision`, `columns` and
`tiles`. Format is `trainlog-dashboard-layout`, version is 1, columns is 12,
and all seven stable Grid V1 IDs occur exactly once. Tiles contain only
`id,x,y,width,height`; responsive projections and translated titles never
persist. The canonical constraint/default table is
`contracts/dashboard-layout-v1.json`; TypeScript consumes it directly and a
Meson contract test rejects C divergence.
Revision zero denotes no valid persisted preference. A successful PUT stores
the expected revision plus one. The maximum is JavaScript's safe integer,
9,007,199,254,740,991. Coordinates are integral, `y <= 200`, dimensions obey
the frozen per-tile bounds, all tiles fit 12 columns and overlap is forbidden.
## Parser and boundedness
Trainlog uses system `yyjson` 0.13.0 (MIT), selected for strict typed C parsing,
small surface and pkg-config/Meson integration. HTTP bodies and files are both
limited to 4 KiB. Only the exact four-level schema is accepted; unexpected
keys, types, array sizes and unsupported versions are rejected. IDs fit a
32-byte bounded buffer and the tile count is exactly seven. No general JSON
framework was introduced.
Reads use `O_NOFOLLOW|O_CLOEXEC|O_NONBLOCK`, `fstat`, regular-file and size
checks. A missing file is normal. An invalid, oversized, non-regular or symlink
preference yields the complete default with source `invalid_persisted`; it is
not silently removed or replaced, and the server emits one bounded diagnostic.
## Atomic mutation
PUT validates before filesystem mutation, creates a same-directory `mkstemp`,
sets 0600 and close-on-exec, writes fully, fsyncs, closes, atomically renames,
then attempts directory fsync. Failures before rename unlink the temporary and
leave the prior file untouched. After rename the visible commit is authoritative
even if directory fsync cannot prove crash durability, so clients are never
invited to retry against a stale revision.
DELETE checks the same revision then unlinks only the preference and attempts
directory fsync. It never touches SQLite. Saving the default through PUT is
valid and deliberate; frontend Reset remains a draft until Save.
## HTTP and mutation security
`GET /api/v1/dashboard-layout` returns default, persisted or invalid-persisted
source, an ETag containing the revision and an ephemeral CSRF token header.
`PUT` and `DELETE` require the loaded ETag in `If-Match`; stale revisions return
412. Missing/malformed preconditions return 428.
Mutations additionally require an exact Origin of either the direct active
loopback URL or `http://trainlog.perf`, plus the 256-bit startup-random token in
`X-Trainlog-CSRF-Token`. The token is never persisted, logged or placed in a
URL. nginx may keep rewriting backend Host to the already accepted
`127.0.0.1:8080`; Origin remains the browser origin and requires no proxy
change. Host validation, loopback binding, no-CORS behavior and request bounds
remain unchanged.
## Frontend lifecycle
The Dashboard withholds the grid behind a discreet loading state until GET
finishes, avoiding default-to-persisted flashing. Network failure falls back to
the default with an accessible warning and disables Save because no safe
revision/token was loaded. Entry into edit mode snapshots the loaded canon;
Cancel is local, Reset changes only the draft, and Save validates then PUTs.
Success adopts the server response and revision. A 412 keeps editing open,
announces the conflict and exposes `Recharger l'agencement`.
Only the 12-column desktop canon is sent. Six-column/tablet and one-column/phone
projections remain derived and cannot trigger mutation.

View file

@ -26,13 +26,13 @@ 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, embedded frontend shell and the frozen Dashboard data
contract and the frozen interactive grid exist. Final tile rendering and layout
persistence remain future slices.
contract, frozen interactive grid and versioned layout persistence exist. Final
tile rendering remains a future slice.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_LAYOUT_V1
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_TILES_V1
```
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
@ -43,6 +43,9 @@ undifferentiated implementation of every Dashboard concern.
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
explicit edit mode, drag/resize, keyboard alternative and derived responsive
projections without persisting layout state.
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` persists only that canonical desktop
layout with atomic XDG storage, optimistic revisions and protected HTTP
mutations; responsive projections remain derived.
## TRAINLOG_WEB_V1 implementation gate
@ -75,7 +78,7 @@ must consume them without reconstructing or redefining their mathematics.
```text
WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_LAYOUT_V1
-> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN]
-> real-data tile delivery
-> real-data visualization delivery
```

View file

@ -76,6 +76,15 @@ normal-mode handles, edit actions, cancel/reset/session-only save, visible
keyboard focus and live announcements. Existing shell tests continue to prove
the factual Footer, navigation and absence of invented data.
`WEB_DASHBOARD_LAYOUT_V1` adds Core-independent filesystem tests for default,
strict parsing, bounds, corruption, oversized/non-regular/symlink inputs,
private permissions, atomic replacement, revision conflicts, preservation on
invalid writes and reset. A canonical JSON parity test prevents C/TypeScript
constraint drift. HTTP tests cover GET/PUT/DELETE, ETag/If-Match, two stale
clients, CSRF/Origin rejection and `trainlog.perf` Origin acceptance. Vitest
covers default/persisted/invalid sources, ETag/token parsing, canonical-only
PUT, conflict translation, loading, local cancel/reset and durable Save.
## 2. Frozen Trainlog JSON v1
Run:

View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
import json
import pathlib
import re
import sys
root = pathlib.Path(__file__).resolve().parents[1]
contract = json.loads((root / "contracts/dashboard-layout-v1.json").read_text())
source = (root / "tui/src/dashboard_layout.c").read_text()
frontend = (root / "web/src/dashboard/dashboardLayout.ts").read_text()
if contract.get("columns") != 12 or contract.get("max_y") != 200 or len(contract.get("tiles", [])) != 7:
sys.exit("invalid canonical dashboard layout contract")
for tile in contract["tiles"]:
constraint = '{{"{}", {}U, {}U, {}U, {}U}}'.format(
tile["id"], tile["min_width"], tile["max_width"], tile["min_height"], tile["max_height"])
default = tile["default"]
default_fragment = '{{"{}", {}U, {}U, {}U, {}U}}'.format(
tile["id"], default["x"], default["y"], default["width"], default["height"])
if constraint not in source or default_fragment not in source:
sys.exit(f"C contract drift: {tile['id']}")
if "dashboard-layout-v1.json" not in frontend:
sys.exit("frontend does not consume canonical dashboard layout contract")
print("PASS dashboard layout contract C/TypeScript parity")

View file

@ -0,0 +1,59 @@
#ifndef TRAINLOG_DASHBOARD_LAYOUT_H
#define TRAINLOG_DASHBOARD_LAYOUT_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT 7U
#define TRAINLOG_DASHBOARD_LAYOUT_COLUMNS 12U
#define TRAINLOG_DASHBOARD_LAYOUT_MAX_Y 200U
#define TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY 4096U
#define TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION 9007199254740991ULL
typedef struct TrainlogDashboardTileLayout {
char id[32];
uint16_t x;
uint16_t y;
uint16_t width;
uint16_t height;
} TrainlogDashboardTileLayout;
typedef struct TrainlogDashboardLayout {
uint64_t revision;
TrainlogDashboardTileLayout tiles[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT];
} TrainlogDashboardLayout;
typedef enum TrainlogDashboardLayoutSource {
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT = 0,
TRAINLOG_DASHBOARD_LAYOUT_PERSISTED,
TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED
} TrainlogDashboardLayoutSource;
typedef enum TrainlogDashboardLayoutResult {
TRAINLOG_DASHBOARD_LAYOUT_OK = 0,
TRAINLOG_DASHBOARD_LAYOUT_INVALID,
TRAINLOG_DASHBOARD_LAYOUT_CONFLICT,
TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR
} TrainlogDashboardLayoutResult;
/* CONTRACT: this is the sole backend owner of the versioned Dashboard UI
* preference. It never accesses SQLite. Callers own all buffers and layouts.
* Revision zero denotes the default/no-valid-file state. */
void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout);
bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout);
TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse(
const char *json, size_t size, TrainlogDashboardLayout *layout);
TrainlogDashboardLayoutResult trainlog_dashboard_layout_load(
TrainlogDashboardLayout *layout, TrainlogDashboardLayoutSource *source);
TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(
const TrainlogDashboardLayout *layout, uint64_t expected_revision,
TrainlogDashboardLayout *saved);
TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(
uint64_t expected_revision);
bool trainlog_dashboard_layout_serialize(const TrainlogDashboardLayout *layout,
TrainlogDashboardLayoutSource source, bool include_source,
char *output, size_t capacity, size_t *size);
bool trainlog_dashboard_layout_path(char *output, size_t capacity);
#endif

View file

@ -8,6 +8,7 @@ mtp_dep = dependency('libmtp', required: true)
notcurses_dep = dependency('notcurses-core', required: true)
microhttpd_dep = dependency('libmicrohttpd', required: true)
yyjson_dep = dependency('yyjson', required: true)
utf8proc_dep = dependency('libutf8proc', required: false)
if not utf8proc_dep.found()
@ -38,12 +39,14 @@ if web_assets_enabled
'../web/tsconfig.json',
'../web/tsconfig.app.json',
'../web/tsconfig.node.json',
'../contracts/dashboard-layout-v1.json',
'../web/vite.config.ts',
'../web/index.html',
'../web/src/main.tsx',
'../web/src/vite-env.d.ts',
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/api/dashboardLayout.ts',
'../web/src/app/App.tsx',
'../web/src/app/routes.ts',
'../web/src/app/useRoute.ts',
@ -239,6 +242,7 @@ trainlog_tui_sources = files(
'src/terminal.c',
'src/theme.c',
'src/tui.c',
'src/dashboard_layout.c',
'src/web_server.c',
)
trainlog_tui_sources += web_assets_source
@ -250,6 +254,7 @@ trainlog_exe = executable(
trainlog_core_dep,
notcurses_dep,
microhttpd_dep,
yyjson_dep,
],
include_directories: trainlog_private_include,
c_args: strict_c_args,
@ -330,12 +335,23 @@ test('cli', test_cli)
test_web_server = executable(
'test_web_server',
['tests/test_web_server.c', 'src/web_server.c', web_assets_source],
dependencies: [trainlog_core_dep, microhttpd_dep],
['tests/test_web_server.c', 'src/web_server.c', 'src/dashboard_layout.c', web_assets_source],
dependencies: [trainlog_core_dep, microhttpd_dep, yyjson_dep],
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
test('web_server', test_web_server, timeout: 30)
test_dashboard_layout = executable(
'test_dashboard_layout',
['tests/test_dashboard_layout.c', 'src/dashboard_layout.c'],
dependencies: yyjson_dep,
include_directories: trainlog_include,
c_args: strict_c_args,
)
test('dashboard_layout', test_dashboard_layout)
test('dashboard_layout_contract', find_program('python3'),
args: [meson.project_source_root() / 'tools/validate_dashboard_layout_contract.py'])
test('cli_help', trainlog_exe, args: ['--help'])
test('cli_version', trainlog_exe, args: ['--version'])

325
tui/src/dashboard_layout.c Normal file
View file

@ -0,0 +1,325 @@
#include "trainlog/dashboard_layout.h"
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <yyjson.h>
typedef struct TileContract {
const char *id;
uint16_t min_width;
uint16_t max_width;
uint16_t min_height;
uint16_t max_height;
} TileContract;
static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 4U, 8U, 3U, 7U},
{"activity", 5U, 12U, 3U, 7U},
{"progression", 6U, 12U, 4U, 9U},
{"last-session", 3U, 7U, 3U, 6U},
{"max-records", 3U, 8U, 3U, 7U},
{"muscle-distribution", 5U, 12U, 4U, 9U},
{"cardio-recovery", 5U, 12U, 4U, 8U}
};
static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {
{"next-session", 0U, 0U, 5U, 4U}, {"activity", 5U, 0U, 7U, 4U},
{"progression", 0U, 4U, 8U, 5U}, {"last-session", 8U, 4U, 4U, 3U},
{"max-records", 8U, 7U, 4U, 3U},
{"muscle-distribution", 0U, 9U, 6U, 5U},
{"cardio-recovery", 6U, 10U, 6U, 4U}
};
static int contract_index(const char *id)
{
size_t index;
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index)
if (strcmp(id, contracts[index].id) == 0) return (int)index;
return -1;
}
void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout)
{
if (layout == NULL) return;
layout->revision = 0U;
(void)memcpy(layout->tiles, defaults, sizeof(defaults));
}
static bool overlap(const TrainlogDashboardTileLayout *a,
const TrainlogDashboardTileLayout *b)
{
return a->x < b->x + b->width && a->x + a->width > b->x &&
a->y < b->y + b->height && a->y + a->height > b->y;
}
bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout)
{
bool seen[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {false};
size_t index;
if (layout == NULL || layout->revision > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION)
return false;
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
const TrainlogDashboardTileLayout *tile = &layout->tiles[index];
int found = contract_index(tile->id);
const TileContract *contract;
size_t other;
if (found < 0 || seen[(size_t)found]) return false;
seen[(size_t)found] = true;
contract = &contracts[(size_t)found];
if (tile->y > TRAINLOG_DASHBOARD_LAYOUT_MAX_Y ||
tile->width < contract->min_width || tile->width > contract->max_width ||
tile->height < contract->min_height || tile->height > contract->max_height ||
tile->x + tile->width > TRAINLOG_DASHBOARD_LAYOUT_COLUMNS)
return false;
for (other = index + 1U; other < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++other)
if (overlap(tile, &layout->tiles[other])) return false;
}
return true;
}
static bool exact_object(const yyjson_val *value, size_t expected)
{
return yyjson_is_obj(value) && yyjson_obj_size(value) == expected;
}
static bool read_u16(yyjson_val *value, uint16_t *output)
{
uint64_t number;
if (!yyjson_is_uint(value)) return false;
number = yyjson_get_uint(value);
if (number > UINT16_MAX) return false;
*output = (uint16_t)number;
return true;
}
static bool equals_uint(yyjson_val *value, uint64_t expected)
{
return yyjson_is_uint(value) && yyjson_get_uint(value) == expected;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse(
const char *json, size_t size, TrainlogDashboardLayout *layout)
{
yyjson_read_err error;
yyjson_doc *document;
yyjson_val *root;
yyjson_val *tiles;
size_t index;
if (json == NULL || layout == NULL || size == 0U ||
size >= TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY)
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
document = yyjson_read_opts((char *)json, size, YYJSON_READ_NOFLAG, NULL, &error);
if (document == NULL) return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
root = yyjson_doc_get_root(document);
if (!exact_object(root, 5U) ||
!yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-dashboard-layout") ||
!equals_uint(yyjson_obj_get(root, "version"), 1U) ||
!equals_uint(yyjson_obj_get(root, "columns"), 12U) ||
!yyjson_is_uint(yyjson_obj_get(root, "revision"))) {
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
layout->revision = yyjson_get_uint(yyjson_obj_get(root, "revision"));
tiles = yyjson_obj_get(root, "tiles");
if (!yyjson_is_arr(tiles) || yyjson_arr_size(tiles) != TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT) {
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
yyjson_val *tile = yyjson_arr_get(tiles, index);
yyjson_val *id = yyjson_obj_get(tile, "id");
const char *text;
size_t length;
if (!exact_object(tile, 5U) || !yyjson_is_str(id) ||
!read_u16(yyjson_obj_get(tile, "x"), &layout->tiles[index].x) ||
!read_u16(yyjson_obj_get(tile, "y"), &layout->tiles[index].y) ||
!read_u16(yyjson_obj_get(tile, "width"), &layout->tiles[index].width) ||
!read_u16(yyjson_obj_get(tile, "height"), &layout->tiles[index].height)) {
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
text = yyjson_get_str(id); length = yyjson_get_len(id);
if (length == 0U || length >= sizeof(layout->tiles[index].id)) {
yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
(void)memcpy(layout->tiles[index].id, text, length);
layout->tiles[index].id[length] = '\0';
}
yyjson_doc_free(document);
return trainlog_dashboard_layout_validate(layout)
? TRAINLOG_DASHBOARD_LAYOUT_OK : TRAINLOG_DASHBOARD_LAYOUT_INVALID;
}
bool trainlog_dashboard_layout_path(char *output, size_t capacity)
{
const char *base = getenv("XDG_CONFIG_HOME");
const char *home;
int length;
if (output == NULL || capacity == 0U) return false;
if (base != NULL && base[0] != '\0')
length = snprintf(output, capacity, "%s/trainlog/web/dashboard-layout-v1.json", base);
else {
home = getenv("HOME");
if (home == NULL || home[0] == '\0') return false;
length = snprintf(output, capacity, "%s/.config/trainlog/web/dashboard-layout-v1.json", home);
}
return length > 0 && (size_t)length < capacity;
}
static bool ensure_private_directories(const char *path)
{
char web[4096];
char trainlog[4096];
char *last;
struct stat status;
if (path == NULL || strlen(path) >= sizeof(web)) return false;
(void)snprintf(web, sizeof(web), "%s", path);
last = strrchr(web, '/'); if (last == NULL) return false; *last = '\0';
(void)snprintf(trainlog, sizeof(trainlog), "%s", web);
last = strrchr(trainlog, '/'); if (last == NULL) return false; *last = '\0';
/* CONTRACT: only the two Trainlog-owned directories are created/chmodded;
* an existing symlink or non-directory is never followed. */
{
if (mkdir(trainlog, 0700) != 0 && errno != EEXIST) return false;
if (lstat(trainlog, &status) != 0 || !S_ISDIR(status.st_mode)) return false;
if (chmod(trainlog, 0700) != 0) return false;
}
if (mkdir(web, 0700) != 0 && errno != EEXIST) return false;
if (lstat(web, &status) != 0 || !S_ISDIR(status.st_mode)) return false;
return chmod(web, 0700) == 0;
}
static bool sync_parent(const char *path)
{
char parent[4096]; char *slash; int fd; bool ok;
if (strlen(path) >= sizeof(parent)) return false;
(void)snprintf(parent, sizeof(parent), "%s", path);
slash = strrchr(parent, '/'); if (slash == NULL) return false; *slash = '\0';
fd = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
if (fd < 0) return false;
ok = fsync(fd) == 0; if (close(fd) != 0) ok = false; return ok;
}
bool trainlog_dashboard_layout_serialize(const TrainlogDashboardLayout *layout,
TrainlogDashboardLayoutSource source, bool include_source,
char *output, size_t capacity, size_t *size)
{
size_t used = 0U; size_t index; int count;
const char *source_name = source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED
? "persisted" : source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED
? "invalid_persisted" : "default";
if (!trainlog_dashboard_layout_validate(layout) || output == NULL || size == NULL)
return false;
#define APPEND(...) do { count = snprintf(output + used, capacity - used, __VA_ARGS__); \
if (count < 0 || (size_t)count >= capacity - used) return false; used += (size_t)count; } while (0)
APPEND("{\"format\":\"trainlog-dashboard-layout\",\"version\":1,"
"\"revision\":%llu,\"columns\":12", (unsigned long long)layout->revision);
if (include_source) APPEND(",\"source\":\"%s\"", source_name);
APPEND(",\"tiles\":[");
for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) {
const TrainlogDashboardTileLayout *tile = &layout->tiles[index];
APPEND("%s{\"id\":\"%s\",\"x\":%u,\"y\":%u,\"width\":%u,\"height\":%u}",
index == 0U ? "" : ",", tile->id, tile->x, tile->y,
tile->width, tile->height);
}
APPEND("]}\n");
#undef APPEND
*size = used; return true;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_load(
TrainlogDashboardLayout *layout, TrainlogDashboardLayoutSource *source)
{
char path[4096]; char buffer[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
struct stat status; ssize_t count; int fd; TrainlogDashboardLayoutResult result;
if (layout == NULL || source == NULL || !trainlog_dashboard_layout_path(path, sizeof(path)))
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
trainlog_dashboard_layout_default(layout); *source = TRAINLOG_DASHBOARD_LAYOUT_DEFAULT;
fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
if (fd < 0) {
if (errno == ENOENT || errno == ELOOP) {
if (errno == ELOOP) *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
if (fstat(fd, &status) != 0 || !S_ISREG(status.st_mode) || status.st_size <= 0 ||
status.st_size >= (off_t)sizeof(buffer)) {
(void)close(fd); *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
count = read(fd, buffer, (size_t)status.st_size);
if (close(fd) != 0 || count != status.st_size) {
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
result = trainlog_dashboard_layout_parse(buffer, (size_t)count, layout);
if (result != TRAINLOG_DASHBOARD_LAYOUT_OK) {
trainlog_dashboard_layout_default(layout);
*source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED;
} else *source = TRAINLOG_DASHBOARD_LAYOUT_PERSISTED;
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
static bool write_all(int fd, const char *data, size_t size)
{
size_t used = 0U;
while (used < size) { ssize_t count = write(fd, data + used, size - used);
if (count < 0 && errno == EINTR) continue; if (count <= 0) return false; used += (size_t)count; }
return true;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_save(
const TrainlogDashboardLayout *layout, uint64_t expected_revision,
TrainlogDashboardLayout *saved)
{
TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source;
char path[4096], temporary[4128], json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
size_t size; int fd; bool ok;
if (layout == NULL || saved == NULL || !trainlog_dashboard_layout_validate(layout) ||
layout->revision != expected_revision || expected_revision >= TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION)
return TRAINLOG_DASHBOARD_LAYOUT_INVALID;
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
*saved = *layout; saved->revision = expected_revision + 1U;
if (!trainlog_dashboard_layout_path(path, sizeof(path)) ||
!ensure_private_directories(path) ||
!trainlog_dashboard_layout_serialize(saved, TRAINLOG_DASHBOARD_LAYOUT_PERSISTED,
false, json, sizeof(json), &size) ||
snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary))
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
fd = mkstemp(temporary);
if (fd < 0) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
ok = fchmod(fd, 0600) == 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 &&
write_all(fd, json, size) && fsync(fd) == 0;
if (close(fd) != 0) ok = false;
if (!ok || rename(temporary, path) != 0) {
(void)unlink(temporary); return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
/* INVARIANT: after rename the valid file is committed. Directory fsync is
* attempted for crash durability, but its failure cannot roll back or make
* a successful visible commit safe to retry against the old revision. */
(void)sync_parent(path);
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}
TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(uint64_t expected_revision)
{
TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source; char path[4096];
if (trainlog_dashboard_layout_load(&current, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK)
return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT;
if (!trainlog_dashboard_layout_path(path, sizeof(path))) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
if (unlink(path) != 0) {
return errno == ENOENT ? TRAINLOG_DASHBOARD_LAYOUT_OK
: TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR;
}
(void)sync_parent(path);
return TRAINLOG_DASHBOARD_LAYOUT_OK;
}

View file

@ -4,6 +4,7 @@
#include <errno.h>
#include <microhttpd.h>
#include <netinet/in.h>
#include <sys/random.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
@ -12,6 +13,7 @@
#include <time.h>
#include "trainlog/web_dashboard.h"
#include "trainlog/dashboard_layout.h"
#include "web_assets.h"
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
@ -26,11 +28,14 @@
typedef struct TrainlogWebContext {
uint16_t port;
TrainlogDatabase *database;
char csrf_token[65];
bool invalid_layout_reported;
} TrainlogWebContext;
typedef struct TrainlogHttpRequestState {
size_t body_size;
bool body_too_large;
char body[TRAINLOG_HTTP_BODY_LIMIT + 1U];
} TrainlogHttpRequestState;
static void web_diagnostic(char *output, size_t capacity, const char *message)
@ -39,6 +44,35 @@ static void web_diagnostic(char *output, size_t capacity, const char *message)
(void)snprintf(output, capacity, "%s", message);
}
static enum MHD_Result queue_layout_json(struct MHD_Connection *connection,
unsigned int status, const char *body, uint64_t revision,
const char *csrf_token)
{
struct MHD_Response *response;
enum MHD_Result result;
char etag[32];
response = MHD_create_response_from_buffer(strlen(body), (void *)body,
MHD_RESPMEM_MUST_COPY);
if (response == NULL) return MHD_NO;
(void)snprintf(etag, sizeof(etag), "\"%llu\"",
(unsigned long long)revision);
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
"application/json; charset=utf-8") != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
"no-store") != MHD_YES ||
MHD_add_response_header(response,
MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES ||
MHD_add_response_header(response, "Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, etag) != MHD_YES ||
MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) {
MHD_destroy_response(response); return MHD_NO;
}
result = MHD_queue_response(connection, status, response);
MHD_destroy_response(response); return result;
}
static enum MHD_Result queue_json(struct MHD_Connection *connection,
unsigned int status, const char *body, const char *allow)
{
@ -139,6 +173,50 @@ static bool declared_body_too_large(struct MHD_Connection *connection)
size > TRAINLOG_HTTP_BODY_LIMIT;
}
static bool valid_origin(const TrainlogWebContext *context, const char *origin)
{
char direct[48];
if (origin == NULL) return false;
(void)snprintf(direct, sizeof(direct), "http://127.0.0.1:%u",
(unsigned int)context->port);
return strcmp(origin, direct) == 0 || strcmp(origin, "http://trainlog.perf") == 0;
}
static bool valid_csrf(const TrainlogWebContext *context, const char *provided)
{
size_t index; unsigned char difference = 0U;
if (provided == NULL || strlen(provided) != 64U) return false;
for (index = 0U; index < 64U; ++index)
difference |= (unsigned char)(provided[index] ^ context->csrf_token[index]);
return difference == 0U;
}
static bool generate_csrf_token(char output[65])
{
unsigned char random_bytes[32];
size_t used = 0U;
size_t index;
while (used < sizeof(random_bytes)) {
ssize_t count = getrandom(random_bytes + used, sizeof(random_bytes) - used, 0);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) return false;
used += (size_t)count;
}
for (index = 0U; index < sizeof(random_bytes); ++index)
(void)snprintf(output + index * 2U, 3U, "%02x", random_bytes[index]);
return true;
}
static bool parse_if_match(const char *value, uint64_t *revision)
{
char *end = NULL; unsigned long long parsed;
if (value == NULL || value[0] != '"') return false;
errno = 0; parsed = strtoull(value + 1, &end, 10);
if (errno != 0 || end == value + 1 || end[0] != '"' || end[1] != '\0' ||
parsed > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) return false;
*revision = (uint64_t)parsed; return true;
}
static enum MHD_Result handle_request(void *closure,
struct MHD_Connection *connection, const char *url, const char *method,
const char *version, const char *upload_data, size_t *upload_data_size,
@ -167,8 +245,11 @@ static enum MHD_Result handle_request(void *closure,
(state->body_size > TRAINLOG_HTTP_BODY_LIMIT
? TRAINLOG_HTTP_BODY_LIMIT : state->body_size))
state->body_too_large = true;
else state->body_size += *upload_data_size;
(void)upload_data;
else {
(void)memcpy(state->body + state->body_size, upload_data, *upload_data_size);
state->body_size += *upload_data_size;
state->body[state->body_size] = '\0';
}
*upload_data_size = 0U;
return MHD_YES;
}
@ -216,6 +297,94 @@ static enum MHD_Result handle_request(void *closure,
"{\"error\":\"dashboard_unavailable\"}\n", NULL);
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
TrainlogDashboardLayout layout;
TrainlogDashboardLayout saved;
TrainlogDashboardLayoutSource source;
TrainlogDashboardLayoutResult layout_result;
char json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
size_t json_size;
uint64_t expected;
const char *origin;
const char *csrf;
const char *if_match;
/* WHY: loopback alone does not stop a hostile webpage from submitting
* requests to a local service. CONTRACT: every mutation needs exact
* trusted Origin, the startup-random header token and an If-Match ETag.
* INVARIANT: proxy Host rewriting remains strict and no CORS is added. */
if (is_get) {
layout_result = trainlog_dashboard_layout_load(&layout, &source);
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK ||
!trainlog_dashboard_layout_serialize(&layout, source, true,
json, sizeof(json), &json_size) || json_size == 0U)
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"layout_unavailable\"}\n", NULL);
if (source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED &&
!context->invalid_layout_reported) {
(void)fprintf(stderr, "Trainlog Web : agencement persisté invalide, défaut utilisé\n");
context->invalid_layout_reported = true;
}
return queue_layout_json(connection, MHD_HTTP_OK, json,
layout.revision, context->csrf_token);
}
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "GET, PUT, DELETE");
origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
"X-Trainlog-CSRF-Token");
if (!valid_origin(context, origin) || !valid_csrf(context, csrf))
return queue_json(connection, MHD_HTTP_FORBIDDEN,
"{\"error\":\"mutation_forbidden\"}\n", NULL);
if_match = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
MHD_HTTP_HEADER_IF_MATCH);
if (!parse_if_match(if_match, &expected))
return queue_json(connection, MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n", NULL);
if (strcmp(method, MHD_HTTP_METHOD_DELETE) == 0) {
if (state->body_size != 0U)
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"unexpected_body\"}\n", NULL);
layout_result = trainlog_dashboard_layout_delete(expected);
if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT)
return queue_json(connection, MHD_HTTP_PRECONDITION_FAILED,
"{\"error\":\"revision_conflict\"}\n", NULL);
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK)
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"layout_delete_failed\"}\n", NULL);
trainlog_dashboard_layout_default(&layout);
if (!trainlog_dashboard_layout_serialize(&layout,
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, true, json, sizeof(json), &json_size))
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"layout_unavailable\"}\n", NULL);
return queue_layout_json(connection, MHD_HTTP_OK, json, 0U,
context->csrf_token);
}
{
const char *content_type = MHD_lookup_connection_value(connection,
MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
if (content_type == NULL || strcmp(content_type, "application/json") != 0)
return queue_json(connection, MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
"{\"error\":\"unsupported_media_type\"}\n", NULL);
}
layout_result = trainlog_dashboard_layout_parse(state->body,
state->body_size, &layout);
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK || layout.revision != expected)
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_layout\"}\n", NULL);
layout_result = trainlog_dashboard_layout_save(&layout, expected, &saved);
if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT)
return queue_json(connection, MHD_HTTP_PRECONDITION_FAILED,
"{\"error\":\"revision_conflict\"}\n", NULL);
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK ||
!trainlog_dashboard_layout_serialize(&saved,
TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, true, json, sizeof(json), &json_size))
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"layout_save_failed\"}\n", NULL);
return queue_layout_json(connection, MHD_HTTP_OK, json, saved.revision,
context->csrf_token);
}
/* INVARIANT: API paths never fall through to the SPA index. */
if (strncmp(url, "/api/", 5U) == 0)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
@ -276,6 +445,12 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
bind_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
context.port = port;
context.database = database;
context.invalid_layout_reported = false;
if (!generate_csrf_token(context.csrf_token)) {
web_diagnostic(diagnostic, diagnostic_capacity,
"impossible de générer la protection CSRF");
return -1;
}
errno = 0;
daemon = MHD_start_daemon(MHD_USE_ERROR_LOG | MHD_USE_NO_THREAD_SAFETY,
0U, accept_loopback_only, NULL, handle_request, &context,

View file

@ -0,0 +1,117 @@
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "trainlog/dashboard_layout.h"
#define CHECK(value) do { if (!(value)) { (void)fprintf(stderr, \
"CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #value); return false; } } while (0)
static bool serialize_file_layout(TrainlogDashboardLayout *layout,
char *json, size_t capacity, size_t *size)
{
return trainlog_dashboard_layout_serialize(layout,
TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, false, json, capacity, size);
}
static bool test_validation(void)
{
TrainlogDashboardLayout layout;
char json[4096]; size_t size;
trainlog_dashboard_layout_default(&layout);
CHECK(trainlog_dashboard_layout_validate(&layout));
CHECK(serialize_file_layout(&layout, json, sizeof(json), &size));
CHECK(trainlog_dashboard_layout_parse(json, size, &layout) == TRAINLOG_DASHBOARD_LAYOUT_OK);
layout.tiles[0].width = 1U; CHECK(!trainlog_dashboard_layout_validate(&layout));
trainlog_dashboard_layout_default(&layout);
(void)snprintf(layout.tiles[1].id, sizeof(layout.tiles[1].id), "%s", layout.tiles[0].id);
CHECK(!trainlog_dashboard_layout_validate(&layout));
trainlog_dashboard_layout_default(&layout); layout.tiles[1].x = 0U;
CHECK(!trainlog_dashboard_layout_validate(&layout));
trainlog_dashboard_layout_default(&layout); layout.tiles[0].y = 201U;
CHECK(!trainlog_dashboard_layout_validate(&layout));
CHECK(trainlog_dashboard_layout_parse("{}", 2U, &layout) == TRAINLOG_DASHBOARD_LAYOUT_INVALID);
return true;
}
static bool write_raw(const char *path, const char *text)
{
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); size_t size = strlen(text);
ssize_t count; if (fd < 0) return false; count = write(fd, text, size);
return close(fd) == 0 && count == (ssize_t)size;
}
static bool test_persistence(void)
{
char root[] = "/tmp/trainlog-layout-test-XXXXXX";
char path[4096]; char target[4096]; char json[4096]; size_t size;
TrainlogDashboardLayout layout, saved, loaded;
TrainlogDashboardLayoutSource source; struct stat status;
CHECK(mkdtemp(root) != NULL); CHECK(setenv("XDG_CONFIG_HOME", root, 1) == 0);
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK);
CHECK(source == TRAINLOG_DASHBOARD_LAYOUT_DEFAULT && loaded.revision == 0U);
trainlog_dashboard_layout_default(&layout);
layout.tiles[3].y = 14U;
CHECK(trainlog_dashboard_layout_validate(&layout));
CHECK(trainlog_dashboard_layout_save(&layout, 0U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_OK);
CHECK(saved.revision == 1U);
CHECK(trainlog_dashboard_layout_path(path, sizeof(path)));
CHECK(lstat(path, &status) == 0 && S_ISREG(status.st_mode) &&
(status.st_mode & 0777U) == 0600U);
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK);
CHECK(source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED && loaded.revision == 1U);
layout = loaded; layout.tiles[0].width = 1U;
CHECK(trainlog_dashboard_layout_save(&layout, 1U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_INVALID);
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
loaded.revision == 1U);
trainlog_dashboard_layout_default(&layout);
layout.tiles[3].y = 14U;
CHECK(trainlog_dashboard_layout_save(&layout, 0U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT);
layout = loaded; layout.tiles[3].y = 18U;
CHECK(trainlog_dashboard_layout_save(&layout, 1U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_OK);
CHECK(saved.revision == 2U);
CHECK(trainlog_dashboard_layout_delete(1U) == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT);
CHECK(trainlog_dashboard_layout_delete(2U) == TRAINLOG_DASHBOARD_LAYOUT_OK);
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
source == TRAINLOG_DASHBOARD_LAYOUT_DEFAULT);
CHECK(snprintf(target, sizeof(target), "%s/target", root) > 0);
CHECK(write_raw(target, "sentinel")); CHECK(symlink(target, path) == 0);
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED);
CHECK(unlink(path) == 0);
CHECK(write_raw(path, "{broken"));
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED);
CHECK(access(path, F_OK) == 0);
CHECK(unlink(path) == 0);
{
char oversized[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
(void)memset(oversized, 'x', sizeof(oversized));
{
int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600);
CHECK(fd >= 0 && write(fd, oversized, sizeof(oversized)) == (ssize_t)sizeof(oversized));
CHECK(close(fd) == 0);
}
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED);
}
CHECK(unlink(path) == 0 && mkdir(path, 0700) == 0);
CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK &&
source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED);
CHECK(rmdir(path) == 0);
CHECK(serialize_file_layout(&loaded, json, sizeof(json), &size));
CHECK(size < sizeof(json));
return true;
}
int main(void)
{
return test_validation() && test_persistence() ? 0 : 1;
}

View file

@ -16,6 +16,7 @@
#include <sqlite3.h>
#include "trainlog/database.h"
#include "trainlog/dashboard_layout.h"
#include "trainlog/web_server.h"
#include "web_assets.h"
@ -123,11 +124,21 @@ static bool asset_path(const char *html, const char *suffix, char *output,
return false;
}
static bool response_header(const char *response, const char *name,
char *output, size_t capacity)
{
const char *start = strstr(response, name); const char *end; size_t length;
if (start == NULL) return false; start += strlen(name);
end = strstr(start, "\r\n"); if (end == NULL) return false;
length = (size_t)(end - start); if (length >= capacity) return false;
(void)memcpy(output, start, length); output[length] = '\0'; return true;
}
static bool test_http_contract(TrainlogDatabase *database)
{
char response[32768];
char large_request[12000];
char request[512];
char request[8192];
char javascript_path[256];
char stylesheet_path[256];
uint16_t port;
@ -167,6 +178,55 @@ static bool test_http_contract(TrainlogDatabase *database)
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
CHECK(strstr(response, "\"window_days\":90") != NULL);
CHECK(strstr(response, "\"partial\":false") != NULL);
{
TrainlogDashboardLayout layout;
char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
char token[80]; char etag[32]; size_t body_size;
CHECK(exchange(port,
"GET /api/v1/dashboard-layout 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, "\"source\":\"default\"") != NULL);
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) && strcmp(etag, "\"0\"") == 0);
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)) && strlen(token) == 64U);
trainlog_dashboard_layout_default(&layout); layout.tiles[3].y = 14U;
CHECK(trainlog_dashboard_layout_serialize(&layout,
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, false, body, sizeof(body), &body_size));
(void)snprintf(request, sizeof(request),
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: wrong\r\n"
"Content-Length: %zu\r\n\r\n%s", (unsigned int)port, body_size, body);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 403") != NULL);
(void)snprintf(request, sizeof(request),
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: text/plain\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\n"
"Content-Length: %zu\r\n\r\n%s", (unsigned int)port, token, body_size, body);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 415") != NULL);
(void)snprintf(request, sizeof(request),
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
"If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: %zu\r\n\r\n%s",
(unsigned int)port, token, body_size, body);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "\"revision\":1") != NULL && strstr(response, "\"source\":\"persisted\"") != NULL);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 412") != NULL);
(void)snprintf(request, sizeof(request),
"DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://trainlog.perf\r\nIf-Match: \"0\"\r\n"
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n", token);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 412") != NULL);
(void)snprintf(request, sizeof(request),
"DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Origin: http://trainlog.perf\r\nIf-Match: \"1\"\r\n"
"X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n", token);
CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"source\":\"default\"") != NULL);
CHECK(exchange(port,
"PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Type: application/json\r\nContent-Length: 2\r\n\r\n{}",
response, sizeof(response)) && strstr(response, "HTTP/1.1 403") != 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 &&
@ -296,8 +356,11 @@ static bool test_dashboard_core_error_translation(void)
int main(void)
{
char config_root[] = "/tmp/trainlog-web-config-XXXXXX";
TrainlogDatabase *database = NULL;
bool passed;
if (mkdtemp(config_root) == NULL || setenv("XDG_CONFIG_HOME", config_root, 1) != 0)
return 1;
if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK)
return 1;
if (!trainlog_web_assets_available()) {

View file

@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest'
import { DEFAULT_DASHBOARD_LAYOUT } from '../dashboard/dashboardLayout'
import { fetchDashboardLayout, parseDashboardLayout, saveDashboardLayout } from './dashboardLayout'
const token = 'a'.repeat(64)
const payload = (source: 'default' | 'persisted' | 'invalid_persisted' = 'default', revision = 0) => ({
format: 'trainlog-dashboard-layout', version: 1, revision, columns: 12,
source, tiles: DEFAULT_DASHBOARD_LAYOUT,
})
describe('API de persistance Dashboard', () => {
it.each([['default', 0], ['persisted', 3], ['invalid_persisted', 0]] as const)(
'valide une source backend %s', (source, revision) => {
expect(parseDashboardLayout(payload(source, revision), `"${revision}"`, token).source).toBe(source)
})
it('rejette une réponse sans ETag/token et un layout invalide', () => {
expect(() => parseDashboardLayout(payload(), '', '')).toThrow()
const invalid = payload(); invalid.tiles = [...DEFAULT_DASHBOARD_LAYOUT, DEFAULT_DASHBOARD_LAYOUT[0]]
expect(() => parseDashboardLayout(invalid, '"0"', token)).toThrow()
})
it('charge le layout et conserve ETag/token', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, status: 200,
headers: new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': token }),
json: () => Promise.resolve(payload()) })))
const loaded = await fetchDashboardLayout()
expect(loaded.etag).toBe('"0"')
expect(loaded.csrfToken).toBe(token)
})
it('envoie uniquement le canon desktop avec If-Match et CSRF', async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => Promise.resolve({
ok: true, status: 200,
headers: new Headers({ ETag: '"1"', 'X-Trainlog-CSRF-Token': token }),
json: () => Promise.resolve(payload('persisted', 1)),
}))
vi.stubGlobal('fetch', fetchMock)
const current = parseDashboardLayout(payload(), '"0"', token)
await saveDashboardLayout(current, DEFAULT_DASHBOARD_LAYOUT)
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.method).toBe('PUT')
expect((init.headers as Record<string, string>)['If-Match']).toBe('"0"')
expect((init.headers as Record<string, string>)['X-Trainlog-CSRF-Token']).toBe(token)
const body = JSON.parse(String(init.body))
expect(body.columns).toBe(12)
expect(body).not.toHaveProperty('source')
expect(body).not.toHaveProperty('breakpoints')
})
it('traduit 412 en conflit explicite', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, status: 412,
headers: new Headers(), json: () => Promise.resolve({ error: 'revision_conflict' }) })))
await expect(saveDashboardLayout(parseDashboardLayout(payload(), '"0"', token),
DEFAULT_DASHBOARD_LAYOUT)).rejects.toThrow('revision_conflict')
})
})

View file

@ -0,0 +1,72 @@
import { TILE_IDS, validateDashboardLayout, type TileLayout } from '../dashboard/dashboardLayout'
export interface DashboardLayoutSnapshot {
format: 'trainlog-dashboard-layout'
version: 1
revision: number
columns: 12
source: 'default' | 'persisted' | 'invalid_persisted'
tiles: TileLayout[]
etag: string
csrfToken: string
}
const record = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
export function parseDashboardLayout(value: unknown, etag: string, csrfToken: string): DashboardLayoutSnapshot {
if (!record(value) || value.format !== 'trainlog-dashboard-layout' || value.version !== 1 ||
value.columns !== 12 || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 ||
!['default', 'persisted', 'invalid_persisted'].includes(String(value.source)) || !Array.isArray(value.tiles))
throw new TypeError('agencement invalide')
const tiles = value.tiles.map((tile) => {
if (!record(tile) || typeof tile.id !== 'string' || !TILE_IDS.includes(tile.id as TileLayout['id']) ||
!Number.isInteger(tile.x) || !Number.isInteger(tile.y) ||
!Number.isInteger(tile.width) || !Number.isInteger(tile.height))
throw new TypeError('tuile d’agencement invalide')
return { id: tile.id, x: tile.x, y: tile.y, width: tile.width, height: tile.height } as TileLayout
})
if (validateDashboardLayout(tiles).length !== 0 || !/^"\d+"$/.test(etag) || csrfToken.length !== 64)
throw new TypeError('contrat d’agencement invalide')
return { ...value, tiles, etag, csrfToken } as DashboardLayoutSnapshot
}
async function parseResponse(response: Response): Promise<DashboardLayoutSnapshot> {
if (!response.ok) throw new Error(`dashboard-layout HTTP ${response.status}`)
return parseDashboardLayout(await response.json(), response.headers.get('ETag') ?? '',
response.headers.get('X-Trainlog-CSRF-Token') ?? '')
}
export async function fetchDashboardLayout(signal?: AbortSignal): Promise<DashboardLayoutSnapshot> {
return parseResponse(await fetch('/api/v1/dashboard-layout', {
headers: { Accept: 'application/json' }, signal,
}))
}
export async function saveDashboardLayout(snapshot: DashboardLayoutSnapshot,
tiles: readonly TileLayout[]): Promise<DashboardLayoutSnapshot> {
const body = {
format: 'trainlog-dashboard-layout', version: 1, revision: snapshot.revision,
columns: 12, tiles,
}
const response = await fetch('/api/v1/dashboard-layout', {
method: 'PUT',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'If-Match': snapshot.etag, 'X-Trainlog-CSRF-Token': snapshot.csrfToken,
},
body: JSON.stringify(body),
})
if (response.status === 412) throw new Error('revision_conflict')
return parseResponse(response)
}
export async function deleteDashboardLayout(snapshot: DashboardLayoutSnapshot): Promise<DashboardLayoutSnapshot> {
const response = await fetch('/api/v1/dashboard-layout', {
method: 'DELETE',
headers: { Accept: 'application/json', 'If-Match': snapshot.etag,
'X-Trainlog-CSRF-Token': snapshot.csrfToken },
})
if (response.status === 412) throw new Error('revision_conflict')
return parseResponse(response)
}

View file

@ -25,11 +25,30 @@ const dashboard: DashboardSnapshot = {
meta: { partial: false, invalid_data: false, generated_at: '2026-09-16T12:00:00Z' },
}
const dashboardLayout = {
format: 'trainlog-dashboard-layout', version: 1, revision: 0, columns: 12,
source: 'default',
tiles: [
{ id: 'next-session', x: 0, y: 0, width: 5, height: 4 },
{ id: 'activity', x: 5, y: 0, width: 7, height: 4 },
{ id: 'progression', x: 0, y: 4, width: 8, height: 5 },
{ id: 'last-session', x: 8, y: 4, width: 4, height: 3 },
{ id: 'max-records', x: 8, y: 7, width: 4, height: 3 },
{ id: 'muscle-distribution', x: 0, y: 9, width: 6, height: 5 },
{ id: 'cardio-recovery', x: 6, y: 10, width: 6, height: 4 },
],
}
const layoutHeaders = new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) })
function responseValue(input: RequestInfo | URL, dashboardValue: unknown, healthValue: unknown) {
const url = String(input)
if (url.includes('/dashboard-layout')) return { ok: true, status: 200, headers: layoutHeaders, json: () => Promise.resolve(dashboardLayout) }
return { ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve(url.includes('/dashboard') ? dashboardValue : healthValue) }
}
function mockHealth(value: unknown = health) {
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({
ok: true,
json: () => Promise.resolve(String(input).includes('/dashboard') ? dashboard : value),
})))
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, dashboard, value))))
}
describe('shell Trainlog', () => {
@ -44,7 +63,7 @@ describe('shell Trainlog', () => {
expect(footer.parentElement).toHaveClass('app-shell')
expect(screen.getByRole('link', { name: 'Trainlog — Dashboard' })).toHaveTextContent('TRAINLOG')
expect(screen.queryByText('Vos repères d’entraînement, sans extrapolation ni donnée inventée.')).not.toBeInTheDocument()
expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7)
await waitFor(() => expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7))
expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—')
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible')
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible')
@ -96,10 +115,7 @@ describe('shell Trainlog', () => {
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),
})))
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, 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')
@ -108,10 +124,7 @@ describe('shell Trainlog', () => {
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),
})))
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, { status: 'ok' }, health))))
render(<App />)
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend connecté'))
expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—')

View file

@ -1,30 +1,44 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DashboardGrid } from './DashboardGrid'
import { DEFAULT_DASHBOARD_LAYOUT } from './dashboardLayout'
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const submitted = init?.method === 'PUT' ? JSON.parse(String(init.body)) : null
return Promise.resolve({
ok: true, status: 200,
headers: new Headers({ ETag: submitted ? '"1"' : '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }),
json: () => Promise.resolve({ format: 'trainlog-dashboard-layout', version: 1,
revision: submitted ? 1 : 0, columns: 12, source: submitted ? 'persisted' : 'default',
tiles: submitted?.tiles ?? DEFAULT_DASHBOARD_LAYOUT }),
})
}))
})
describe('grille interactive du Dashboard', () => {
it('est verrouillée par défaut sans poignées de redimensionnement', () => {
it('est verrouillée par défaut sans poignées de redimensionnement', async () => {
const { container } = render(<DashboardGrid />)
expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()
expect(container.querySelector('.dashboard-layout')).not.toHaveClass('is-editing')
expect(container.querySelectorAll('.react-resizable-handle')).toHaveLength(0)
expect(screen.queryByRole('article', { name: /tuile modifiable/i })).not.toBeInTheDocument()
})
it('active drag, resize et les commandes transactionnelles en mode édition', () => {
it('active drag, resize et les commandes transactionnelles en mode édition', async () => {
const { container } = render(<DashboardGrid />)
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
expect(container.querySelector('.dashboard-layout')).toHaveClass('is-editing')
expect(screen.getByRole('button', { name: 'Annuler' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Réinitialiser' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Enregistrer pour cette session' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Enregistrer' })).toBeInTheDocument()
expect(container.querySelectorAll('.react-resizable-handle').length).toBeGreaterThan(0)
expect(screen.getByRole('article', { name: 'Progression, tuile modifiable' })).toHaveAttribute('tabindex', '0')
})
it('déplace et redimensionne une tuile au clavier avec annonce accessible', () => {
it('déplace et redimensionne une tuile au clavier avec annonce accessible', async () => {
render(<DashboardGrid />)
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' })
tile.focus()
expect(tile).toHaveFocus()
@ -34,11 +48,11 @@ describe('grille interactive du Dashboard', () => {
expect(screen.getByText(/Progression :.*largeur 9/)).toBeInTheDocument()
})
it('Annuler restaure le layout d’entrée et Enregistrer conserve l’état React', () => {
it('Annuler restaure le layout d’entrée et Enregistrer persiste l’état React', async () => {
const { container } = render(<DashboardGrid />)
const progression = () => container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
const initial = progression().style.width
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowRight', shiftKey: true })
expect(progression().style.width).not.toBe(initial)
fireEvent.click(screen.getByRole('button', { name: 'Annuler' }))
@ -47,18 +61,47 @@ describe('grille interactive du Dashboard', () => {
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowRight', shiftKey: true })
const edited = progression().style.width
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer pour cette session' }))
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument())
expect(progression().style.width).toBe(edited)
expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()
})
it('Réinitialiser restaure le défaut dans le brouillon', () => {
it('Réinitialiser restaure le défaut dans le brouillon', async () => {
const { container } = render(<DashboardGrid />)
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' })
fireEvent.keyDown(tile, { key: 'ArrowRight', shiftKey: true })
const modified = (container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement).style.width
fireEvent.click(screen.getByRole('button', { name: 'Réinitialiser' }))
expect((container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement).style.width).not.toBe(modified)
})
it('conserve le brouillon et propose un reload lors d’un conflit 412', async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === 'PUT') return Promise.resolve({ ok: false, status: 412,
headers: new Headers(), json: () => Promise.resolve({ error: 'revision_conflict' }) })
return Promise.resolve({ ok: true, status: 200,
headers: new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }),
json: () => Promise.resolve({ format: 'trainlog-dashboard-layout', version: 1,
revision: 0, columns: 12, source: 'default', tiles: DEFAULT_DASHBOARD_LAYOUT }) })
})
vi.stubGlobal('fetch', fetchMock)
render(<DashboardGrid />)
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
expect(await screen.findByRole('alert')).toHaveTextContent('modifié dans un autre onglet')
expect(screen.getByRole('button', { name: 'Recharger l’agencement' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Annuler' })).toBeInTheDocument()
})
it('ne sauvegarde jamais depuis la projection téléphone', async () => {
window.innerWidth = 390
const fetchMock = vi.mocked(fetch)
render(<DashboardGrid />)
fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
expect(screen.getByRole('button', { name: 'Enregistrer' })).toBeDisabled()
expect(screen.getByText(/écran large/)).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(1)
window.innerWidth = 1440
})
})

View file

@ -1,7 +1,8 @@
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
import { EmptyState } from '../components/EmptyState'
import { Tile } from '../components/Tile'
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
import {
cloneLayout,
DEFAULT_DASHBOARD_LAYOUT,
@ -12,6 +13,7 @@ import {
tileSize,
TILE_IDS,
toGridLayout,
validateDashboardLayout,
type TileId,
type TileLayout,
} from './dashboardLayout'
@ -36,6 +38,10 @@ export function DashboardGrid() {
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
const [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('')
const [snapshot, setSnapshot] = useState<DashboardLayoutSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [preferenceError, setPreferenceError] = useState('')
const beforeEditing = useRef<TileLayout[]>(cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
// The viewport-derived seed prevents a wide first frame from overflowing on
// phones before ResizeObserver has measured the actual grid container.
@ -49,6 +55,23 @@ export function DashboardGrid() {
[layout, view.columns],
)
async function reloadLayout() {
setLoading(true)
try {
const loaded = await fetchDashboardLayout()
setSnapshot(loaded)
setLayout(cloneLayout(loaded.tiles))
setPreferenceError(loaded.source === 'invalid_persisted'
? 'L’agencement enregistré est invalide. Le défaut Trainlog est utilisé.' : '')
} catch {
setSnapshot(null)
setLayout(cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
setPreferenceError('Impossible de charger l’agencement enregistré. Le défaut Trainlog est utilisé.')
} finally { setLoading(false) }
}
useEffect(() => { void reloadLayout() }, [])
function enterEditMode() {
beforeEditing.current = cloneLayout(layout)
setEditing(true)
@ -66,9 +89,21 @@ export function DashboardGrid() {
setAnnouncement('Agencement par défaut restauré dans le brouillon.')
}
function saveEditing() {
setEditing(false)
setAnnouncement('Agencement appliqué pour cette session. Il reviendra à sa valeur par défaut après rechargement.')
async function saveEditing() {
if (!desktop || snapshot === null || validateDashboardLayout(layout).length !== 0) {
setPreferenceError('Impossible d’enregistrer cet agencement.')
return
}
setSaving(true); setPreferenceError('')
try {
const saved = await saveDashboardLayout(snapshot, layout)
setSnapshot(saved); setLayout(cloneLayout(saved.tiles)); setEditing(false)
setAnnouncement('Agencement enregistré durablement.')
} catch (error) {
if (error instanceof Error && error.message === 'revision_conflict')
setPreferenceError('L’agencement a été modifié dans un autre onglet. Rechargez l’agencement courant.')
else setPreferenceError('L’enregistrement de l’agencement a échoué.')
} finally { setSaving(false) }
}
function acceptLibraryLayout(next: Layout) {
@ -94,6 +129,8 @@ export function DashboardGrid() {
const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns)
if (loading) return <p className="layout-loading" role="status">Chargement de l’agencement…</p>
return (
<div className={`dashboard-layout${editing ? ' is-editing' : ''}`}>
<div className="layout-toolbar" aria-label="Agencement du Dashboard">
@ -105,10 +142,11 @@ export function DashboardGrid() {
{!desktop && <p className="layout-breakpoint-note">Revenez sur un écran large pour modifier la grille canonique.</p>}
<button className="layout-action" type="button" onClick={cancelEditing}>Annuler</button>
<button className="layout-action" type="button" onClick={resetEditing}>Réinitialiser</button>
<button className="layout-action layout-action-primary" type="button" onClick={saveEditing}>Enregistrer pour cette session</button>
<button className="layout-action layout-action-primary" type="button" disabled={saving || snapshot === null || !desktop} onClick={() => void saveEditing()}>{saving ? 'Enregistrement…' : 'Enregistrer'}</button>
</>
)}
</div>
{preferenceError && <div className="layout-message is-error" role="alert">{preferenceError}{preferenceError.includes('autre onglet') && <button className="layout-action" type="button" onClick={() => void reloadLayout()}>Recharger l’agencement</button>}</div>}
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
<GridLayout

View file

@ -1,4 +1,5 @@
import { moveElement, verticalCompactor, type Layout as GridLayout } from 'react-grid-layout'
import contract from '../../../contracts/dashboard-layout-v1.json'
export const DASHBOARD_COLUMNS = 12
export const DASHBOARD_MAX_Y = 200
@ -30,25 +31,14 @@ export interface TileConstraint {
maxHeight: number
}
export const TILE_CONSTRAINTS: Readonly<Record<TileId, TileConstraint>> = {
'next-session': { minWidth: 4, maxWidth: 8, minHeight: 3, maxHeight: 7 },
activity: { minWidth: 5, maxWidth: 12, minHeight: 3, maxHeight: 7 },
progression: { minWidth: 6, maxWidth: 12, minHeight: 4, maxHeight: 9 },
'last-session': { minWidth: 3, maxWidth: 7, minHeight: 3, maxHeight: 6 },
'max-records': { minWidth: 3, maxWidth: 8, minHeight: 3, maxHeight: 7 },
'muscle-distribution': { minWidth: 5, maxWidth: 12, minHeight: 4, maxHeight: 9 },
'cardio-recovery': { minWidth: 5, maxWidth: 12, minHeight: 4, maxHeight: 8 },
}
export const TILE_CONSTRAINTS = Object.fromEntries(contract.tiles.map((tile) => [tile.id, {
minWidth: tile.min_width, maxWidth: tile.max_width,
minHeight: tile.min_height, maxHeight: tile.max_height,
}])) as Readonly<Record<TileId, TileConstraint>>
export const DEFAULT_DASHBOARD_LAYOUT: readonly TileLayout[] = [
{ id: 'next-session', x: 0, y: 0, width: 5, height: 4 },
{ id: 'activity', x: 5, y: 0, width: 7, height: 4 },
{ id: 'progression', x: 0, y: 4, width: 8, height: 5 },
{ id: 'last-session', x: 8, y: 4, width: 4, height: 3 },
{ id: 'max-records', x: 8, y: 7, width: 4, height: 3 },
{ id: 'muscle-distribution', x: 0, y: 9, width: 6, height: 5 },
{ id: 'cardio-recovery', x: 6, y: 10, width: 6, height: 4 },
] as const
export const DEFAULT_DASHBOARD_LAYOUT: readonly TileLayout[] = contract.tiles.map((tile) => ({
id: tile.id as TileId, ...tile.default,
}))
const tileIdSet = new Set<string>(TILE_IDS)

View file

@ -58,6 +58,10 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
.layout-action-primary:hover { color: var(--crust); background: var(--blue); border-color: transparent; }
.layout-help, .layout-breakpoint-note { margin: 0 auto 0 0; color: var(--subtext-0); font: 550 .7rem/1.4 var(--font-mono); }
.layout-breakpoint-note { flex-basis: 100%; color: var(--yellow); }
.layout-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
.layout-message { margin: 0 0 1rem; padding: .7rem .85rem; display: flex; align-items: center; gap: .75rem; color: var(--subtext-0); background: var(--surface-0); border-radius: .55rem; font-size: .82rem; }
.layout-message.is-error { color: var(--peach); }
.layout-action:disabled { opacity: .55; cursor: not-allowed; }
.dashboard-grid-frame { min-width: 0; }
.dashboard-grid { min-width: 0; }
.dashboard-grid > .react-grid-item { min-width: 0; }