feat(web): freeze interactive dashboard grid
This commit is contained in:
parent
6f34233751
commit
3973a60233
17 changed files with 753 additions and 42 deletions
|
|
@ -262,9 +262,9 @@ must not require either. All production assets are local, built ahead of time,
|
|||
and incorporated into the `trainlog` binary from the sibling root `web/`
|
||||
directory. The shell implements semantic Header/Body/Footer landmarks,
|
||||
keyboard-visible focus, responsive tile scaffolding and reduced-motion rules.
|
||||
Absent user/session/zone facts remain visibly unavailable. The grid library
|
||||
remains open pending a dedicated spike; ECharts remains deferred until a real
|
||||
visualization exists.
|
||||
Absent user/session/zone facts remain visibly unavailable. The bounded grid
|
||||
engine uses `react-grid-layout` 2.2.4 behind a Trainlog-owned model, adapter and
|
||||
validator; ECharts remains deferred until a real visualization exists.
|
||||
|
||||
The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles:
|
||||
|
||||
|
|
@ -276,12 +276,14 @@ The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles:
|
|||
6. Répartition musculaire;
|
||||
7. Cardio / récupération.
|
||||
|
||||
V1 supports drag and drop, horizontal and vertical resizing, tile-specific
|
||||
minimum/maximum sizes, automatic reorganization, responsive projection,
|
||||
persistence, and restoration of the Trainlog default. Dragging and resizing
|
||||
are enabled only by the explicit `Modifier l'agencement` mode. A larger tile
|
||||
may reveal more information, but tile size never changes a metric's business
|
||||
definition.
|
||||
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` supports drag and drop, horizontal and
|
||||
vertical resizing, tile-specific minimum/maximum sizes, automatic
|
||||
reorganization, responsive projection, persistence in the next tranche, and
|
||||
restoration of the Trainlog default. Dragging and resizing are enabled only by
|
||||
the explicit `Modifier l'agencement` mode. A larger tile may reveal more
|
||||
information, but tile size never changes a metric's business definition. Until
|
||||
`WEB_DASHBOARD_LAYOUT_V1`, saving accepts a valid draft only in current React
|
||||
memory and reload restores the default.
|
||||
|
||||
The single persisted source layout is the canonical desktop 12-column layout:
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ parallel implementation of its rules.
|
|||
| TUI Dashboard adoption | `WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` |
|
||||
| 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` |
|
||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||
|
||||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||
|
|
@ -191,6 +192,13 @@ 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.
|
||||
|
||||
`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
|
||||
resize, an announced keyboard alternative, and derived six/one-column
|
||||
responsive projections. No layout is persisted yet.
|
||||
|
||||
`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
|
||||
|
|
@ -206,6 +214,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`.
|
||||
- `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.
|
||||
|
|
|
|||
86
docs/design/web_dashboard_grid_v1.md
Normal file
86
docs/design/web_dashboard_grid_v1.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Web Dashboard grid V1
|
||||
|
||||
Status: `WEB_DASHBOARD_GRID_V1=PASS/FROZEN`
|
||||
|
||||
## Library spike
|
||||
|
||||
The spike was performed on 2026-09-16 against React 19.3.0, TypeScript 7 and
|
||||
Vite 8.3.0. Package metadata and upstream documentation were checked before
|
||||
installation.
|
||||
|
||||
| Candidate | Version checked | Fit | Decision |
|
||||
|---|---:|---|---|
|
||||
| `react-grid-layout` | 2.2.4 | MIT; React peer `>=16.3`; TypeScript v2 implementation; 12-column layouts, drag, two-axis resize, per-item min/max, configurable enablement, vertical compaction, responsive primitives and serializable layouts. Active 2026 releases. Mouse interaction is mature but has no complete keyboard grid editor. | Selected, with a Trainlog keyboard layer and independent model/validator. |
|
||||
| `gridstack` | 13.3.0 | MIT; active maintenance; framework-neutral drag, resize, responsive columns and constraints. Its package is substantially larger and React integration remains an imperative wrapper around a DOM engine rather than a native typed React state boundary. | Rejected for this React-owned surface. |
|
||||
| `@dnd-kit/core` | 6.3.1 | MIT; strong sensors and keyboard support, but it is a drag/drop toolkit rather than a heterogeneous dashboard-grid engine. It supplies neither resize nor the required collision/compaction policy; upstream guidance requires a custom sorting strategy for mixed grid sizes. | Rejected because Trainlog would have to build the missing engine. |
|
||||
|
||||
`react-grid-layout` has no external service, runtime telemetry, CDN or backend
|
||||
dependency. Trainlog imports the v2 API directly and does not expose the
|
||||
library's `{i,w,h}` representation outside its adapter.
|
||||
|
||||
## Trainlog model and invariants
|
||||
|
||||
The authoritative frontend shape is:
|
||||
|
||||
```text
|
||||
TileLayout { id, x, y, width, height }
|
||||
```
|
||||
|
||||
The seven stable IDs are `next-session`, `activity`, `progression`,
|
||||
`last-session`, `max-records`, `muscle-distribution`, and `cardio-recovery`.
|
||||
The desktop layout alone is canonical and uses 12 columns. The adapter maps it
|
||||
to and from the library shape. A separate Trainlog validator requires exactly
|
||||
those IDs, integral coordinates and dimensions, each tile's min/max bounds,
|
||||
`x >= 0`, `y >= 0`, `x + width <= 12`, `y <= 200`, and no overlap.
|
||||
|
||||
The deterministic default is:
|
||||
|
||||
| Tile | x | y | width | height | min/max width | min/max height |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Prochaine séance | 0 | 0 | 5 | 4 | 4–8 | 3–7 |
|
||||
| Activité | 5 | 0 | 7 | 4 | 5–12 | 3–7 |
|
||||
| Progression | 0 | 4 | 8 | 5 | 6–12 | 4–9 |
|
||||
| Dernière séance | 8 | 4 | 4 | 3 | 3–7 | 3–6 |
|
||||
| Records / MAX | 8 | 7 | 4 | 3 | 3–8 | 3–7 |
|
||||
| Répartition musculaire | 0 | 9 | 6 | 5 | 5–12 | 4–9 |
|
||||
| Cardio / récupération | 6 | 10 | 6 | 4 | 5–12 | 4–8 |
|
||||
|
||||
Vertical compaction is deterministic; overlap is forbidden and collisions
|
||||
push/reflow other items. Library output is accepted into React state only after
|
||||
conversion and Trainlog validation.
|
||||
|
||||
## Interaction and responsive contract
|
||||
|
||||
Normal mode disables drag and resize and emits no resize handles. Explicit
|
||||
`Modifier l'agencement` mode enables them on desktop. `Annuler` restores the
|
||||
entry snapshot, `Réinitialiser` restores the Trainlog default in the edit
|
||||
draft, and `Enregistrer pour cette session` accepts the draft only in current
|
||||
React memory. Reload deliberately restores the default: persistence belongs to
|
||||
`WEB_DASHBOARD_LAYOUT_V1`.
|
||||
|
||||
Focused tiles support Arrow keys for movement and Shift+Arrow keys for resize.
|
||||
Focus is visible and a polite live region announces logical coordinates and
|
||||
dimensions. Mouse resize exposes east, south and south-east handles. Reduced
|
||||
motion disables grid transitions.
|
||||
|
||||
Widths below 1100 px derive a six-column projection; widths below 700 px derive
|
||||
a stable one-column projection. These projections preserve canonical ordering,
|
||||
remain non-authoritative and never update the desktop layout. Editing at those
|
||||
breakpoints is deliberately disabled with an explicit explanation.
|
||||
|
||||
The existing `/api/v1/dashboard` fetch and factual Footer remain unchanged.
|
||||
Tile bodies retain placeholders; logical `compact`, `medium`, and `large`
|
||||
presentation levels depend only on tile dimensions and add no business facts.
|
||||
|
||||
## Bundle impact
|
||||
|
||||
Vite production output before the dependency was 229.07 kB JavaScript and
|
||||
7.14 kB CSS. V1 output is 301.61 kB JavaScript and 13.42 kB CSS: an increase of
|
||||
72.54 kB JavaScript and 6.28 kB CSS before transport compression. This is
|
||||
accepted for the complete drag, resize, constraint and compaction engine.
|
||||
|
||||
Upstream references:
|
||||
|
||||
- <https://github.com/react-grid-layout/react-grid-layout>
|
||||
- <https://github.com/gridstack/gridstack.js>
|
||||
- <https://github.com/clauderic/dnd-kit>
|
||||
|
|
@ -26,12 +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 exist. Final tile rendering and layout remain future slices.
|
||||
contract and the frozen interactive grid exist. Final tile rendering and layout
|
||||
persistence remain future slices.
|
||||
|
||||
## Current cursor
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_GRID_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_LAYOUT_V1
|
||||
```
|
||||
|
||||
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
|
||||
|
|
@ -39,6 +40,9 @@ TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
|
|||
system, permanent Header/Footer, empty-data states and health status. The next
|
||||
Dashboard tranche is partitioned below; this cursor does not authorize an
|
||||
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.
|
||||
|
||||
## TRAINLOG_WEB_V1 implementation gate
|
||||
|
||||
|
|
@ -70,8 +74,8 @@ must consume them without reconstructing or redefining their mathematics.
|
|||
|
||||
```text
|
||||
WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN]
|
||||
-> grid-library spike and bounded grid engine
|
||||
-> separately versioned layout persistence
|
||||
-> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN]
|
||||
-> WEB_DASHBOARD_LAYOUT_V1
|
||||
-> real-data tile delivery
|
||||
-> real-data visualization delivery
|
||||
```
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ 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.
|
||||
|
||||
`WEB_DASHBOARD_GRID_V1` adds Vitest coverage for the seven stable identities,
|
||||
default validity/non-overlap, every min/max constraint, independent rejection
|
||||
of unknown/duplicate/out-of-grid/non-integral layouts, lossless library adapter
|
||||
round trips, deterministic collision compaction, horizontal/vertical keyboard
|
||||
resize, responsive six/one-column projections, normal-mode locking, absent
|
||||
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.
|
||||
|
||||
## 2. Frozen Trainlog JSON v1
|
||||
|
||||
Run:
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ if web_assets_enabled
|
|||
'../web/src/components/Footer.tsx',
|
||||
'../web/src/components/Header.tsx',
|
||||
'../web/src/components/Tile.tsx',
|
||||
'../web/src/dashboard/DashboardGrid.tsx',
|
||||
'../web/src/dashboard/dashboardLayout.ts',
|
||||
'../web/src/routes/DashboardPage.tsx',
|
||||
'../web/src/routes/PlaceholderPage.tsx',
|
||||
'../web/src/theme/app.css',
|
||||
|
|
|
|||
112
web/package-lock.json
generated
112
web/package-lock.json
generated
|
|
@ -9,7 +9,8 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"react-dom": "19.3.0",
|
||||
"react-grid-layout": "2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
|
|
@ -1200,6 +1201,15 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
|
|
@ -1332,6 +1342,12 @@
|
|||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
|
||||
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -1399,9 +1415,7 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.0.1",
|
||||
|
|
@ -1717,6 +1731,18 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
|
|
@ -1784,6 +1810,15 @@
|
|||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
|
|
@ -1876,6 +1911,23 @@
|
|||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
@ -1907,6 +1959,38 @@
|
|||
"react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
|
||||
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-grid-layout": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz",
|
||||
"integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"fast-equals": "^4.0.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-resizable": "^3.1.3",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
|
|
@ -1915,6 +1999,20 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-resizable": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
|
||||
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "15.x",
|
||||
"react-draggable": "^4.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3",
|
||||
"react-dom": ">= 16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
|
|
@ -1939,6 +2037,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
"react-dom": "19.3.0",
|
||||
"react-grid-layout": "2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import type { KeyboardEventHandler, ReactNode } from 'react'
|
||||
import type { TileSize } from '../dashboard/dashboardLayout'
|
||||
|
||||
interface TileProps {
|
||||
title: string
|
||||
eyebrow?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
size?: TileSize
|
||||
editable?: boolean
|
||||
onKeyDown?: KeyboardEventHandler<HTMLElement>
|
||||
}
|
||||
|
||||
export function Tile({ title, eyebrow, children, className = '' }: TileProps) {
|
||||
export function Tile({ title, eyebrow, children, className = '', size = 'medium', editable = false, onKeyDown }: TileProps) {
|
||||
return (
|
||||
<article className={`tile ${className}`.trim()}>
|
||||
<div className="tile-header">
|
||||
<article
|
||||
className={`tile tile-${size} ${className}`.trim()}
|
||||
data-size={size}
|
||||
tabIndex={editable ? 0 : undefined}
|
||||
aria-label={editable ? `${title}, tuile modifiable` : undefined}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className={editable ? 'tile-header tile-drag-handle' : 'tile-header'}>
|
||||
<div>
|
||||
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
|
||||
<h2>{title}</h2>
|
||||
|
|
|
|||
64
web/src/dashboard/DashboardGrid.test.tsx
Normal file
64
web/src/dashboard/DashboardGrid.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DashboardGrid } from './DashboardGrid'
|
||||
|
||||
describe('grille interactive du Dashboard', () => {
|
||||
it('est verrouillée par défaut sans poignées de redimensionnement', () => {
|
||||
const { container } = render(<DashboardGrid />)
|
||||
expect(screen.getByRole('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', () => {
|
||||
const { container } = render(<DashboardGrid />)
|
||||
fireEvent.click(screen.getByRole('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(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', () => {
|
||||
render(<DashboardGrid />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
|
||||
const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' })
|
||||
tile.focus()
|
||||
expect(tile).toHaveFocus()
|
||||
fireEvent.keyDown(tile, { key: 'ArrowDown' })
|
||||
expect(screen.getByText(/Progression : colonne \d+, ligne \d+, largeur \d+, hauteur \d+\./)).toBeInTheDocument()
|
||||
fireEvent.keyDown(tile, { key: 'ArrowRight', shiftKey: true })
|
||||
expect(screen.getByText(/Progression :.*largeur 9/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Annuler restaure le layout d’entrée et Enregistrer conserve l’état React', () => {
|
||||
const { container } = render(<DashboardGrid />)
|
||||
const progression = () => container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement
|
||||
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' }))
|
||||
expect(progression().style.width).toBe(initial)
|
||||
|
||||
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' }))
|
||||
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', () => {
|
||||
const { container } = render(<DashboardGrid />)
|
||||
fireEvent.click(screen.getByRole('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)
|
||||
})
|
||||
})
|
||||
145
web/src/dashboard/DashboardGrid.tsx
Normal file
145
web/src/dashboard/DashboardGrid.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { 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 {
|
||||
cloneLayout,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
fromGridLayout,
|
||||
moveTileByKeyboard,
|
||||
projectDashboardLayout,
|
||||
resizeTileByKeyboard,
|
||||
tileSize,
|
||||
TILE_IDS,
|
||||
toGridLayout,
|
||||
type TileId,
|
||||
type TileLayout,
|
||||
} from './dashboardLayout'
|
||||
|
||||
const dashboardTiles: Readonly<Record<TileId, { title: string, eyebrow: string, message: string }>> = {
|
||||
'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', message: 'Aucune séance préparée disponible.' },
|
||||
activity: { title: 'Activité', eyebrow: 'Régularité', message: 'Les données d’activité ne sont pas encore exposées.' },
|
||||
progression: { title: 'Progression', eyebrow: 'Évolution', message: 'La métrique de progression reste à définir.' },
|
||||
'last-session': { title: 'Dernière séance', eyebrow: 'Historique', message: 'Aucune séance terminée disponible.' },
|
||||
'max-records': { title: 'Records / MAX', eyebrow: 'Performances', message: 'Aucun MAX mesuré disponible.' },
|
||||
'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', message: 'La répartition par zone n’est pas encore disponible.' },
|
||||
'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', message: 'Aucune donnée cardio ou récupération disponible.' },
|
||||
}
|
||||
|
||||
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
|
||||
if (width < 700) return { columns: 1, rowHeight: 56 }
|
||||
if (width < 1100) return { columns: 6, rowHeight: 54 }
|
||||
return { columns: 12, rowHeight: 52 }
|
||||
}
|
||||
|
||||
export function DashboardGrid() {
|
||||
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [announcement, setAnnouncement] = 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.
|
||||
const initialWidth = Math.max(288, window.innerWidth - 32)
|
||||
const { width, containerRef } = useContainerWidth({ initialWidth })
|
||||
const effectiveWidth = width > 0 ? width : initialWidth
|
||||
const view = breakpoint(effectiveWidth)
|
||||
const desktop = view.columns === 12
|
||||
const visibleLayout = useMemo(
|
||||
() => projectDashboardLayout(layout, view.columns),
|
||||
[layout, view.columns],
|
||||
)
|
||||
|
||||
function enterEditMode() {
|
||||
beforeEditing.current = cloneLayout(layout)
|
||||
setEditing(true)
|
||||
setAnnouncement('Mode modification activé. Utilisez les flèches pour déplacer une tuile et Maj plus flèche pour la redimensionner.')
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setLayout(cloneLayout(beforeEditing.current))
|
||||
setEditing(false)
|
||||
setAnnouncement('Modifications annulées.')
|
||||
}
|
||||
|
||||
function resetEditing() {
|
||||
setLayout(cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||
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.')
|
||||
}
|
||||
|
||||
function acceptLibraryLayout(next: Layout) {
|
||||
if (!editing || !desktop) return
|
||||
const converted = fromGridLayout(next)
|
||||
if (converted !== null) setLayout(converted)
|
||||
}
|
||||
|
||||
function onTileKeyDown(event: KeyboardEvent<HTMLElement>, id: TileId) {
|
||||
if (!editing || !desktop || !event.key.startsWith('Arrow')) return
|
||||
event.preventDefault()
|
||||
const horizontal = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0
|
||||
const vertical = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0
|
||||
const next = event.shiftKey
|
||||
? resizeTileByKeyboard(layout, id, horizontal, vertical)
|
||||
: moveTileByKeyboard(layout, id, horizontal, vertical)
|
||||
setLayout(next)
|
||||
const tile = next.find((candidate) => candidate.id === id)
|
||||
if (tile !== undefined) {
|
||||
setAnnouncement(`${dashboardTiles[id].title} : colonne ${tile.x + 1}, ligne ${tile.y + 1}, largeur ${tile.width}, hauteur ${tile.height}.`)
|
||||
}
|
||||
}
|
||||
|
||||
const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns)
|
||||
|
||||
return (
|
||||
<div className={`dashboard-layout${editing ? ' is-editing' : ''}`}>
|
||||
<div className="layout-toolbar" aria-label="Agencement du Dashboard">
|
||||
{!editing ? (
|
||||
<button className="layout-action layout-action-primary" type="button" onClick={enterEditMode}>Modifier l’agencement</button>
|
||||
) : (
|
||||
<>
|
||||
<p className="layout-help">Flèches : déplacer · Maj + flèches : redimensionner</p>
|
||||
{!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>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
width={effectiveWidth}
|
||||
layout={libraryLayout}
|
||||
gridConfig={{ cols: view.columns, rowHeight: view.rowHeight, margin: [16, 16], containerPadding: [0, 0], maxRows: 240 }}
|
||||
dragConfig={{ enabled: editing && desktop, bounded: true, handle: '.tile-drag-handle', cancel: 'button, a, input, select, textarea', threshold: 3 }}
|
||||
resizeConfig={{ enabled: editing && desktop, handles: editing && desktop ? ['e', 's', 'se'] : [] }}
|
||||
compactor={verticalCompactor}
|
||||
onLayoutChange={acceptLibraryLayout}
|
||||
className="dashboard-grid"
|
||||
>
|
||||
{TILE_IDS.map((id) => {
|
||||
const content = dashboardTiles[id]
|
||||
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
|
||||
return (
|
||||
<div key={id} data-testid={`grid-item-${id}`}>
|
||||
<Tile
|
||||
title={content.title}
|
||||
eyebrow={content.eyebrow}
|
||||
size={tileSize(dimensions)}
|
||||
editable={editing && desktop}
|
||||
onKeyDown={(event) => onTileKeyDown(event, id)}
|
||||
>
|
||||
<EmptyState message={content.message} />
|
||||
</Tile>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</GridLayout>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
web/src/dashboard/dashboardLayout.test.ts
Normal file
82
web/src/dashboard/dashboardLayout.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DASHBOARD_COLUMNS,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
fromGridLayout,
|
||||
moveTileByKeyboard,
|
||||
projectDashboardLayout,
|
||||
resizeTileByKeyboard,
|
||||
TILE_CONSTRAINTS,
|
||||
TILE_IDS,
|
||||
toGridLayout,
|
||||
validateDashboardLayout,
|
||||
type TileLayout,
|
||||
} from './dashboardLayout'
|
||||
|
||||
describe('contrat de layout Dashboard Trainlog', () => {
|
||||
it('définit exactement sept IDs stables dans un layout par défaut valide', () => {
|
||||
expect(DEFAULT_DASHBOARD_LAYOUT).toHaveLength(7)
|
||||
expect(DEFAULT_DASHBOARD_LAYOUT.map((tile) => tile.id)).toEqual(TILE_IDS)
|
||||
expect(validateDashboardLayout(DEFAULT_DASHBOARD_LAYOUT)).toEqual([])
|
||||
})
|
||||
|
||||
it('respecte les contraintes de chaque tuile et les 12 colonnes', () => {
|
||||
for (const tile of DEFAULT_DASHBOARD_LAYOUT) {
|
||||
const constraint = TILE_CONSTRAINTS[tile.id]
|
||||
expect(tile.width).toBeGreaterThanOrEqual(constraint.minWidth)
|
||||
expect(tile.width).toBeLessThanOrEqual(constraint.maxWidth)
|
||||
expect(tile.height).toBeGreaterThanOrEqual(constraint.minHeight)
|
||||
expect(tile.height).toBeLessThanOrEqual(constraint.maxHeight)
|
||||
expect(tile.x + tile.width).toBeLessThanOrEqual(DASHBOARD_COLUMNS)
|
||||
}
|
||||
})
|
||||
|
||||
it('convertit vers la bibliothèque et revient sans perte de données Trainlog', () => {
|
||||
const library = toGridLayout(DEFAULT_DASHBOARD_LAYOUT, true)
|
||||
expect(library.every((tile) => tile.isDraggable && tile.isResizable)).toBe(true)
|
||||
expect(fromGridLayout(library)).toEqual(DEFAULT_DASHBOARD_LAYOUT)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ID inconnu', (layout: TileLayout[]) => { layout[0] = { ...layout[0], id: 'unknown' as TileLayout['id'] } }, 'unknown_id'],
|
||||
['doublon', (layout: TileLayout[]) => { layout[1] = { ...layout[1], id: layout[0].id } }, 'duplicate_id'],
|
||||
['dimension non entière', (layout: TileLayout[]) => { layout[0] = { ...layout[0], width: 4.5 } }, 'non_integer'],
|
||||
['dimension hors contrainte', (layout: TileLayout[]) => { layout[0] = { ...layout[0], width: 1 } }, 'size_constraint'],
|
||||
['position hors grille', (layout: TileLayout[]) => { layout[0] = { ...layout[0], x: 10 } }, 'grid_bounds'],
|
||||
['chevauchement', (layout: TileLayout[]) => { layout[1] = { ...layout[1], x: 0 } }, 'overlap'],
|
||||
])('rejette un layout invalide : %s', (_label, mutate, expected) => {
|
||||
const layout = DEFAULT_DASHBOARD_LAYOUT.map((tile) => ({ ...tile }))
|
||||
mutate(layout)
|
||||
expect(validateDashboardLayout(layout).some((error) => error.startsWith(expected))).toBe(true)
|
||||
})
|
||||
|
||||
it('projette tablette et téléphone sans modifier le canon desktop', () => {
|
||||
const canonical = DEFAULT_DASHBOARD_LAYOUT.map((tile) => ({ ...tile }))
|
||||
const tablet = projectDashboardLayout(canonical, 6)
|
||||
const phone = projectDashboardLayout(canonical, 1)
|
||||
expect(tablet.every((tile) => tile.x === 0 && tile.width <= 6)).toBe(true)
|
||||
expect(phone.every((tile) => tile.x === 0 && tile.width === 1)).toBe(true)
|
||||
expect(phone.map((tile) => tile.id)).toEqual(TILE_IDS)
|
||||
expect(canonical).toEqual(DEFAULT_DASHBOARD_LAYOUT)
|
||||
expect(projectDashboardLayout(canonical, 12)).toEqual(DEFAULT_DASHBOARD_LAYOUT)
|
||||
})
|
||||
|
||||
it('déplace au clavier, résout la collision et compacte sans chevauchement', () => {
|
||||
const moved = moveTileByKeyboard(DEFAULT_DASHBOARD_LAYOUT, 'last-session', -4, 0)
|
||||
expect(validateDashboardLayout(moved)).toEqual([])
|
||||
expect(moved).not.toEqual(DEFAULT_DASHBOARD_LAYOUT)
|
||||
})
|
||||
|
||||
it('redimensionne horizontalement et verticalement en respectant min/max', () => {
|
||||
let layout = resizeTileByKeyboard(DEFAULT_DASHBOARD_LAYOUT, 'progression', 1, 1)
|
||||
expect(validateDashboardLayout(layout)).toEqual([])
|
||||
const grown = layout.find((tile) => tile.id === 'progression')!
|
||||
expect(grown.width).toBe(9)
|
||||
expect(grown.height).toBe(6)
|
||||
for (let count = 0; count < 20; count += 1) layout = resizeTileByKeyboard(layout, 'progression', 1, 1)
|
||||
const bounded = layout.find((tile) => tile.id === 'progression')!
|
||||
expect(bounded.width).toBe(TILE_CONSTRAINTS.progression.maxWidth)
|
||||
expect(bounded.height).toBe(TILE_CONSTRAINTS.progression.maxHeight)
|
||||
expect(validateDashboardLayout(layout)).toEqual([])
|
||||
})
|
||||
})
|
||||
175
web/src/dashboard/dashboardLayout.ts
Normal file
175
web/src/dashboard/dashboardLayout.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { moveElement, verticalCompactor, type Layout as GridLayout } from 'react-grid-layout'
|
||||
|
||||
export const DASHBOARD_COLUMNS = 12
|
||||
export const DASHBOARD_MAX_Y = 200
|
||||
|
||||
export const TILE_IDS = [
|
||||
'next-session',
|
||||
'activity',
|
||||
'progression',
|
||||
'last-session',
|
||||
'max-records',
|
||||
'muscle-distribution',
|
||||
'cardio-recovery',
|
||||
] as const
|
||||
|
||||
export type TileId = typeof TILE_IDS[number]
|
||||
|
||||
export interface TileLayout {
|
||||
id: TileId
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface TileConstraint {
|
||||
minWidth: number
|
||||
maxWidth: number
|
||||
minHeight: number
|
||||
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 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
|
||||
|
||||
const tileIdSet = new Set<string>(TILE_IDS)
|
||||
|
||||
function overlaps(a: TileLayout, b: TileLayout): boolean {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTRACT: validation is owned by Trainlog and deliberately does not trust
|
||||
* react-grid-layout. The same rules can therefore guard the future persisted
|
||||
* dashboard-layout-v1.json without exposing a library-specific structure.
|
||||
*/
|
||||
export function validateDashboardLayout(value: readonly TileLayout[]): string[] {
|
||||
const errors: string[] = []
|
||||
const seen = new Set<string>()
|
||||
if (value.length !== TILE_IDS.length) errors.push('tile_count')
|
||||
|
||||
value.forEach((tile, index) => {
|
||||
if (!tileIdSet.has(tile.id)) errors.push(`unknown_id:${String(tile.id)}`)
|
||||
if (seen.has(tile.id)) errors.push(`duplicate_id:${tile.id}`)
|
||||
seen.add(tile.id)
|
||||
const values = [tile.x, tile.y, tile.width, tile.height]
|
||||
if (!values.every(Number.isInteger)) errors.push(`non_integer:${tile.id}`)
|
||||
if (tile.x < 0 || tile.y < 0) errors.push(`negative_position:${tile.id}`)
|
||||
if (tile.y > DASHBOARD_MAX_Y) errors.push(`y_limit:${tile.id}`)
|
||||
const constraint = TILE_CONSTRAINTS[tile.id]
|
||||
if (constraint !== undefined && (
|
||||
tile.width < constraint.minWidth || tile.width > constraint.maxWidth ||
|
||||
tile.height < constraint.minHeight || tile.height > constraint.maxHeight
|
||||
)) errors.push(`size_constraint:${tile.id}`)
|
||||
if (tile.x + tile.width > DASHBOARD_COLUMNS) errors.push(`grid_bounds:${tile.id}`)
|
||||
for (let other = index + 1; other < value.length; other += 1) {
|
||||
if (overlaps(tile, value[other])) errors.push(`overlap:${tile.id}:${value[other].id}`)
|
||||
}
|
||||
})
|
||||
|
||||
for (const id of TILE_IDS) {
|
||||
if (!seen.has(id)) errors.push(`missing_id:${id}`)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function cloneLayout(layout: readonly TileLayout[]): TileLayout[] {
|
||||
return layout.map((tile) => ({ ...tile }))
|
||||
}
|
||||
|
||||
export function toGridLayout(layout: readonly TileLayout[], editable: boolean, columns: 12 | 6 | 1 = 12): GridLayout {
|
||||
return layout.map((tile) => {
|
||||
const constraint = TILE_CONSTRAINTS[tile.id]
|
||||
return {
|
||||
i: tile.id,
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
w: tile.width,
|
||||
h: tile.height,
|
||||
minW: columns === 12 ? constraint.minWidth : tile.width,
|
||||
maxW: columns === 12 ? constraint.maxWidth : tile.width,
|
||||
minH: columns === 12 ? constraint.minHeight : tile.height,
|
||||
maxH: columns === 12 ? constraint.maxHeight : tile.height,
|
||||
isDraggable: editable,
|
||||
isResizable: editable,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function fromGridLayout(layout: GridLayout): TileLayout[] | null {
|
||||
const converted = layout.map((tile) => ({
|
||||
id: tile.i as TileId,
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
width: tile.w,
|
||||
height: tile.h,
|
||||
}))
|
||||
return validateDashboardLayout(converted).length === 0 ? converted : null
|
||||
}
|
||||
|
||||
/**
|
||||
* INVARIANT: responsive layouts are projections only. Stable desktop order is
|
||||
* preserved and the projected coordinates are never written to canonical state.
|
||||
*/
|
||||
export function projectDashboardLayout(layout: readonly TileLayout[], columns: 12 | 6 | 1): TileLayout[] {
|
||||
if (columns === 12) return cloneLayout(layout)
|
||||
const ordered = [...layout].sort((a, b) => a.y - b.y || a.x - b.x || TILE_IDS.indexOf(a.id) - TILE_IDS.indexOf(b.id))
|
||||
let y = 0
|
||||
return ordered.map((tile) => {
|
||||
const projectedWidth = columns === 1 ? 1 : Math.min(columns, Math.max(3, Math.round(tile.width / 2)))
|
||||
const projectedHeight = columns === 1 ? Math.max(3, Math.min(6, tile.height)) : tile.height
|
||||
const projected = { ...tile, x: 0, y, width: projectedWidth, height: projectedHeight }
|
||||
y += projectedHeight
|
||||
return projected
|
||||
})
|
||||
}
|
||||
|
||||
export type TileSize = 'compact' | 'medium' | 'large'
|
||||
|
||||
export function tileSize(tile: TileLayout): TileSize {
|
||||
if (tile.width >= 8 || tile.height >= 6) return 'large'
|
||||
if (tile.width >= 5 || tile.height >= 4) return 'medium'
|
||||
return 'compact'
|
||||
}
|
||||
|
||||
function constrainGridLayout(layout: GridLayout): GridLayout {
|
||||
return layout.map((item) => {
|
||||
const id = item.i as TileId
|
||||
const constraint = TILE_CONSTRAINTS[id]
|
||||
const w = Math.min(constraint.maxWidth, Math.max(constraint.minWidth, item.w))
|
||||
const h = Math.min(constraint.maxHeight, Math.max(constraint.minHeight, item.h))
|
||||
return { ...item, w, h, x: Math.min(Math.max(0, item.x), DASHBOARD_COLUMNS - w), y: Math.max(0, item.y) }
|
||||
})
|
||||
}
|
||||
|
||||
export function moveTileByKeyboard(layout: readonly TileLayout[], id: TileId, dx: number, dy: number): TileLayout[] {
|
||||
const grid = toGridLayout(layout, true)
|
||||
const item = grid.find((candidate) => candidate.i === id)
|
||||
if (item === undefined) return cloneLayout(layout)
|
||||
const moved = moveElement(grid, item, item.x + dx, Math.max(0, item.y + dy), true, false, 'vertical', DASHBOARD_COLUMNS, false)
|
||||
return fromGridLayout(verticalCompactor.compact(moved, DASHBOARD_COLUMNS)) ?? cloneLayout(layout)
|
||||
}
|
||||
|
||||
export function resizeTileByKeyboard(layout: readonly TileLayout[], id: TileId, dw: number, dh: number): TileLayout[] {
|
||||
const grid = toGridLayout(layout, true).map((item) => item.i === id ? { ...item, w: item.w + dw, h: item.h + dh } : item)
|
||||
return fromGridLayout(verticalCompactor.compact(constrainGridLayout(grid), DASHBOARD_COLUMNS)) ?? cloneLayout(layout)
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './app/App'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
import 'react-resizable/css/styles.css'
|
||||
import './theme/tokens.css'
|
||||
import './theme/app.css'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,4 @@
|
|||
import { EmptyState } from '../components/EmptyState'
|
||||
import { Tile } from '../components/Tile'
|
||||
|
||||
const dashboardTiles = [
|
||||
['Prochaine séance', 'Planification', 'Aucune séance préparée disponible.'],
|
||||
['Activité', 'Régularité', 'Les données d’activité ne sont pas encore exposées.'],
|
||||
['Progression', 'Évolution', 'La métrique de progression reste à définir.'],
|
||||
['Dernière séance', 'Historique', 'Aucune séance terminée disponible.'],
|
||||
['Records / MAX', 'Performances', 'Aucun MAX mesuré disponible.'],
|
||||
['Répartition musculaire', 'Zones', 'La répartition par zone n’est pas encore disponible.'],
|
||||
['Cardio / récupération', 'Physiologie', 'Aucune donnée cardio ou récupération disponible.'],
|
||||
] as const
|
||||
import { DashboardGrid } from '../dashboard/DashboardGrid'
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
|
|
@ -17,14 +6,7 @@ export function DashboardPage() {
|
|||
<div className="page-heading">
|
||||
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
|
||||
</div>
|
||||
<div className="tile-grid dashboard-preview">
|
||||
{dashboardTiles.map(([title, eyebrow, message], index) => (
|
||||
<Tile key={title} title={title} eyebrow={eyebrow}
|
||||
className={index === 0 || index === 2 ? 'tile-wide' : ''}>
|
||||
<EmptyState message={message} />
|
||||
</Tile>
|
||||
))}
|
||||
</div>
|
||||
<DashboardGrid />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, vi } from 'vitest'
|
|||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
Object.defineProperty(window, 'scrollTo', { value: vi.fn(), writable: true })
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1440, writable: true })
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
|
|
|||
|
|
@ -47,6 +47,34 @@ h1, h2, p { margin-top: 0; }
|
|||
h1 { margin-bottom: 0; font-size: clamp(2.25rem, 5vw, 4.5rem); line-height: .96; letter-spacing: -.055em; font-weight: 720; }
|
||||
h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
|
||||
.page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
.dashboard-layout { min-width: 0; }
|
||||
.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }
|
||||
.layout-action { min-height: 2.45rem; padding: .58rem .82rem; color: var(--subtext-0); background: var(--surface-0); border: 1px solid rgb(108 112 134 / 48%); border-radius: .55rem; font: 650 .74rem var(--font-mono); cursor: pointer; transition: color 140ms ease, border-color 140ms ease, background 140ms ease; }
|
||||
.layout-action:hover { color: var(--text); border-color: var(--surface-2); background: var(--surface-1); }
|
||||
.layout-action:focus-visible, .tile:focus-visible { outline: 2px solid var(--lavender); outline-offset: 3px; }
|
||||
.layout-action-primary { color: var(--crust); background: var(--lavender); border-color: transparent; }
|
||||
.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); }
|
||||
.dashboard-grid-frame { min-width: 0; }
|
||||
.dashboard-grid { min-width: 0; }
|
||||
.dashboard-grid > .react-grid-item { min-width: 0; }
|
||||
.dashboard-grid .tile { width: 100%; height: 100%; min-height: 0; }
|
||||
.dashboard-layout.is-editing .dashboard-grid-frame { margin: -.5rem; padding: .5rem; border-radius: calc(var(--radius) + .3rem); background-image: radial-gradient(circle, rgb(180 190 254 / 16%) 1px, transparent 1px); background-size: 1.5rem 1.5rem; }
|
||||
.dashboard-layout.is-editing .tile { border-color: rgb(180 190 254 / 38%); box-shadow: 0 1.2rem 3rem rgb(0 0 0 / 16%), inset 0 0 0 1px rgb(180 190 254 / 5%); }
|
||||
.dashboard-layout.is-editing .tile-drag-handle { cursor: grab; }
|
||||
.dashboard-layout.is-editing .react-draggable-dragging .tile-drag-handle { cursor: grabbing; }
|
||||
.dashboard-grid .react-grid-placeholder { background: var(--lavender); border-radius: var(--radius); opacity: .16; }
|
||||
.dashboard-grid .react-grid-item > .react-resizable-handle { display: none; width: 1.2rem; height: 1.2rem; opacity: .8; }
|
||||
.dashboard-layout.is-editing .dashboard-grid .react-grid-item > .react-resizable-handle { display: block; }
|
||||
.dashboard-grid .react-resizable-handle::after { width: .45rem; height: .45rem; border-color: var(--lavender); }
|
||||
.dashboard-grid .react-resizable-handle-e { top: 50%; right: -.15rem; transform: translateY(-50%) rotate(135deg); }
|
||||
.dashboard-grid .react-resizable-handle-s { bottom: -.15rem; left: 50%; transform: translateX(-50%) rotate(45deg); }
|
||||
.tile-compact .empty-glyph { display: none; }
|
||||
.tile-compact .empty-state p { margin-top: 0; font-size: .82rem; }
|
||||
.tile-large .tile-content { padding: 1rem; }
|
||||
|
||||
.tile-grid { min-width: 0; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1rem; }
|
||||
.tile { min-width: 0; grid-column: span 4; min-height: 17rem; padding: clamp(1.15rem, 2vw, 1.6rem); display: flex; flex-direction: column; background: var(--base); border: 1px solid rgb(69 71 90 / 38%); border-radius: var(--radius); box-shadow: 0 1.2rem 3rem rgb(0 0 0 / 12%); }
|
||||
|
|
@ -91,6 +119,8 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
|
|||
.tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; }
|
||||
.tile { min-height: 14rem; }
|
||||
.principle-panel { min-height: 17rem; }
|
||||
.layout-toolbar { justify-content: flex-start; }
|
||||
.layout-help { flex-basis: 100%; }
|
||||
.status-footer { grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; }
|
||||
.status-zone { grid-column: 1 / -1; }
|
||||
.backend-state { grid-column: 1 / -1; justify-self: start; }
|
||||
|
|
@ -105,4 +135,5 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
|
|||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
.react-grid-item { transition: none !important; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue