From c04fa76f0b32e53962f81c8d173ffbb6e42f1cad Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 16 Sep 2026 21:26:43 +0200 Subject: [PATCH] fix(web): complete dashboard v1 final review --- docs/architecture.md | 9 ++++++++ docs/current_state.md | 7 +++--- docs/reviews/web_dashboard_v1.md | 28 +++++++++++++++++++++++ docs/roadmap.md | 13 +++++++++-- docs/tests.md | 7 ++++++ web/src/charts/ProgressionChart.test.tsx | 28 ++++++++++++++++++++++- web/src/charts/ProgressionChart.tsx | 22 +++++++++++++++++- web/src/dashboard/DashboardTiles.test.tsx | 1 + web/src/theme/app.css | 18 ++++++++++----- 9 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 docs/reviews/web_dashboard_v1.md diff --git a/docs/architecture.md b/docs/architecture.md index 4152a31..d79a4b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -338,6 +338,15 @@ the maximum `session_count` in the current snapshot: zero, up to one third, up to two thirds, and above two thirds. They communicate comparison only, never fatigue, recovery, balance, volume, or physiological quality. +`WEB_DASHBOARD_V1=PASS/FROZEN` closes the Dashboard after final review. The +Progression value axis uses explicit deterministic bounds: constant series get +a symmetric margin of at least 1 kg (rounded outward to 0.5 kg), while varying +series get at least 0.5 kg or 10% of their observed span. The lower bound is +never below zero, and every real observation—including legacy zero—is retained. +Large Progression tiles place facts and chart side by side so the complete +domain stays visible without an internal scroll. Dashboard layout persistence, +responsive projections and the seven domain semantics remain unchanged. + `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: diff --git a/docs/current_state.md b/docs/current_state.md index 2b57df9..2f45240 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -46,6 +46,7 @@ parallel implementation of its rules. | Web Dashboard layout | `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` | | Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` | | Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` | +| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | Desktop and Android schema numbers are independent. Neither changes the frozen @@ -233,9 +234,9 @@ smoke test are not automated. The latter is why ## Active limitations -- `WEB_DASHBOARD_V1_FINAL_REVIEW`: global visual consistency, runtime - performance and remaining placeholder decisions await the final bounded - Dashboard V1 review. +- `WEB_NEXT_MODULE_SELECTION_V1`: Analyse, Programmes, Séances and Exercices + remain shell placeholders until their Core/API ownership and implementation + order are explicitly contracted. - `APP_SHELL_V1` still awaits the recorded human visual/accessibility review. - AI proposal exchange still awaits one real Drive plus Android-triggered bidirectional smoke test. diff --git a/docs/reviews/web_dashboard_v1.md b/docs/reviews/web_dashboard_v1.md new file mode 100644 index 0000000..77fcfdb --- /dev/null +++ b/docs/reviews/web_dashboard_v1.md @@ -0,0 +1,28 @@ +# Web Dashboard V1 final review + +Status: `WEB_DASHBOARD_V1=PASS/FROZEN` + +The final review used the real user database through `http://trainlog.perf`. +All seven tiles, normal/edit modes, desktop and narrow projections, fixed +statusline, persistent layout reload/restart and the direct/proxied API were +exercised without changing training data. + +Three bounded visual corrections close V1: + +- Progression owns deterministic honest Y bounds. Constant series are centred, + varying series retain proportional breathing room, and legacy zero remains + visible. A large tile uses a two-column factual-summary/chart presentation. +- A large muscle tile assigns approximately 38% of its internal grid to the + front/rear figure and increases its vertical presence, without changing the + BODY ZONES mapping or `session_count` colour semantics. +- resize handles keep their 1.2 rem interactive target while their chevron and + resting opacity are reduced; hover and focus-within restore emphasis. + +The layout persistence smoke saved a moved/resized seven-tile layout, verified +it after HTTP reload and `trainlog-web` restart, then explicitly saved the +canonical default again. The final real file is regular, mode `0600`, revision +5, and contains only the desktop 12-column Trainlog layout. + +The roadmap does not currently order Analyse, Programmes, Séances and +Exercices. The next cursor is therefore `WEB_NEXT_MODULE_SELECTION_V1`: a +bounded contract-selection pass, not an implicit implementation. diff --git a/docs/roadmap.md b/docs/roadmap.md index 619e341..cba5ffa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -32,7 +32,7 @@ tile rendering exist. Advanced visualizations remain a future slice. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_V1_FINAL_REVIEW +CURRENT_OPERATIONAL_CURSOR=WEB_NEXT_MODULE_SELECTION_V1 ``` `WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React, @@ -51,6 +51,9 @@ size-adaptive component consuming only the frozen Dashboard snapshot. `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` adds a factual time-series chart for the Core-selected performance identity and an original accessible BODY ZONES silhouette, without changing either domain's semantics. +`WEB_DASHBOARD_V1=PASS/FROZEN` closes the complete Dashboard after its final +functional, visual, responsive, accessibility, persistence and performance +review. ## TRAINLOG_WEB_V1 implementation gate @@ -86,13 +89,19 @@ WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN] -> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN] -> WEB_DASHBOARD_TILES_V1 [PASS/FROZEN] -> WEB_DASHBOARD_VISUALIZATIONS_V1 [PASS/FROZEN] - -> WEB_DASHBOARD_V1_FINAL_REVIEW + -> WEB_DASHBOARD_V1_FINAL_REVIEW [PASS/FROZEN] ``` The slice order may be refined by the next contract, but it must preserve the existing Core ownership, keep visual layout outside business SQLite and frozen formats, and specify Activité/Progression before implementing those metrics. +`WEB_NEXT_MODULE_SELECTION_V1` is the next documentary cursor. The current +roadmap does not yet establish a contract order between Analyse, Programmes, +Séances and Exercices. This cursor must inventory their existing Core services, +read/write boundaries and dependencies, then select one bounded module contract; +it does not authorize silently implementing a page. + ## Next ### Gym catalog V1 diff --git a/docs/tests.md b/docs/tests.md index 10d8757..0c75e57 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -101,6 +101,13 @@ front/back views, keyboard-focusable regions and factual muscle tooltips. The existing Activity, MAX, session, unavailable, Footer and Grid/Layout tests remain unchanged and passing. +`WEB_DASHBOARD_V1_FINAL_REVIEW` adds explicit value-axis coverage for an empty +defensive domain, one point, identical points, low amplitude, normal amplitude +and a legacy zero. Every observation must remain within the computed bounds and +a positive constant series must have visible space on both sides. Tile-density, +BODY ZONES text preservation, edit handles, keyboard editing and responsive +non-persistence remain covered by the existing Grid and tile suites. + ## 2. Frozen Trainlog JSON v1 Run: diff --git a/web/src/charts/ProgressionChart.test.tsx b/web/src/charts/ProgressionChart.test.tsx index 6c01a6d..cb152d0 100644 --- a/web/src/charts/ProgressionChart.test.tsx +++ b/web/src/charts/ProgressionChart.test.tsx @@ -1,9 +1,13 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { dashboardFixture } from '../test/dashboardFixtures' -import { ProgressionChart, progressionCollisionMetadata, progressionTooltip } from './ProgressionChart' +import { ProgressionChart, progressionCollisionMetadata, progressionTooltip, progressionYAxisBounds } from './ProgressionChart' describe('courbe de progression factuelle', () => { + it('fournit un domaine défensif borné sans observation', () => { + expect(progressionYAxisBounds([])).toEqual({ min: 0, max: 1 }) + }) + it('construit un tooltip exclusivement depuis le point et son identité', () => { const progression = dashboardFixture().data.progression if (!progression.available) throw new Error('fixture invalide') @@ -26,6 +30,28 @@ describe('courbe de progression factuelle', () => { ]) }) + it.each([ + ['un point', [18], { min: 16, max: 20 }], + ['plusieurs points identiques', [18, 18, 18], { min: 16, max: 20 }], + ['faible amplitude', [18, 18.25], { min: 17.5, max: 18.75 }], + ['amplitude normale', [20, 40, 60], { min: 16, max: 64 }], + ['legacy zéro', [0, 18], { min: 0, max: 20 }], + ] as const)('calcule une échelle honnête pour %s', (_label, weights, expected) => { + const progression = dashboardFixture().data.progression + if (!progression.available) throw new Error('fixture invalide') + const points = weights.map((weight_kg, index) => ({ ...progression.points[0], weight_kg, session_id: `scale-${index}` })) + const bounds = progressionYAxisBounds(points) + expect(bounds).toEqual(expected) + for (const value of weights) { + expect(value).toBeGreaterThanOrEqual(bounds.min) + expect(value).toBeLessThanOrEqual(bounds.max) + } + if (weights.every((value) => value === weights[0]) && weights[0] > 0) { + expect(bounds.min).toBeLessThan(weights[0]) + expect(bounds.max).toBeGreaterThan(weights[0]) + } + }) + it('conserve une description textuelle accessible pour un et plusieurs points', async () => { const progression = dashboardFixture().data.progression if (!progression.available) throw new Error('fixture invalide') diff --git a/web/src/charts/ProgressionChart.tsx b/web/src/charts/ProgressionChart.tsx index 0303223..cca7953 100644 --- a/web/src/charts/ProgressionChart.tsx +++ b/web/src/charts/ProgressionChart.tsx @@ -19,6 +19,25 @@ interface ChartDatum { label?: { show: boolean; formatter: string; position: 'top'; color: string; fontSize: number } } +export interface ProgressionYAxisBounds { min: number; max: number } + +export function progressionYAxisBounds(points: ProgressionPoint[]): ProgressionYAxisBounds { + if (points.length === 0) return { min: 0, max: 1 } + const weights = points.map((point) => point.weight_kg) + const observedMin = Math.min(...weights) + const observedMax = Math.max(...weights) + const span = observedMax - observedMin + // CONTRACT: bounds add visual breathing room only; every persisted value, + // including a legacy zero, remains unchanged and inside the visible domain. + const rawMargin = span === 0 + ? Math.max(1, Math.abs(observedMax) * 0.1) + : Math.max(0.5, span * 0.1) + const margin = Math.ceil(rawMargin * 2) / 2 + const min = Math.max(0, observedMin - margin) + const max = observedMax + margin + return { min, max: max > min ? max : min + 1 } +} + export function progressionCollisionMetadata(points: ProgressionPoint[]): Array<{ count: number; index: number }> { const keys = points.map((point) => `${point.timestamp}\u0000${point.weight_kg}`) const totals = new Map() @@ -50,6 +69,7 @@ export function ProgressionChart({ identity, points }: ProgressionChartProps) { const element = host.current const theme = trainlogChartTheme(element) const chart = echarts.init(element, undefined, { renderer: 'svg' }) + const yBounds = progressionYAxisBounds(points) const collisions = progressionCollisionMetadata(points) const data: ChartDatum[] = points.map((point, index) => { const collision = collisions[index] @@ -80,7 +100,7 @@ export function ProgressionChart({ identity, points }: ProgressionChartProps) { axisLabel: { color: theme.subtext, fontSize: 12, hideOverlap: true }, splitLine: { show: false }, }, yAxis: { - type: 'value', name: 'kg', scale: true, nameTextStyle: { color: theme.subtext, fontSize: 12 }, + type: 'value', name: 'kg', min: yBounds.min, max: yBounds.max, scale: true, nameTextStyle: { color: theme.subtext, fontSize: 12 }, axisLabel: { color: theme.subtext, fontSize: 12, formatter: '{value} kg' }, axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: theme.surface0 } }, }, diff --git a/web/src/dashboard/DashboardTiles.test.tsx b/web/src/dashboard/DashboardTiles.test.tsx index eeb7eb5..1780bba 100644 --- a/web/src/dashboard/DashboardTiles.test.tsx +++ b/web/src/dashboard/DashboardTiles.test.tsx @@ -84,6 +84,7 @@ describe('tuiles Dashboard alimentées par le contrat', () => { rerender() expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument() expect(screen.getByText(/Couleur : séances sur 30 jours/)).toBeInTheDocument() + expect(screen.getByText('Corps entier')).toBeInTheDocument() }) it('gère les états indisponibles des domaines optionnels', () => { diff --git a/web/src/theme/app.css b/web/src/theme/app.css index c5f5b56..11abf0c 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -71,9 +71,11 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .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-grid .react-grid-item > .react-resizable-handle { display: none; width: 1.2rem; height: 1.2rem; opacity: .42; transition: opacity 120ms ease; } .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-layout.is-editing .dashboard-grid .react-grid-item > .react-resizable-handle:hover, +.dashboard-layout.is-editing .dashboard-grid .react-grid-item:focus-within > .react-resizable-handle { opacity: .85; } +.dashboard-grid .react-resizable-handle::after { width: .38rem; height: .38rem; 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; } @@ -108,21 +110,23 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .chart-legend { margin: .75rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); line-height: 1.45; }.legend-swatch { display: inline-block; width: .55rem; height: .55rem; margin-right: .3rem; background: var(--lavender); border-radius: .12rem; } .primary-label { margin: 0; color: var(--text); font-size: var(--font-size-lg); line-height: 1.3; font-weight: 650; }.secondary-label { margin: .3rem 0 0; color: var(--subtext-0); font-size: var(--font-size-sm); line-height: 1.4; } .progression-latest { margin: .9rem 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem .7rem; }.progression-latest strong { color: var(--lavender); font-size: clamp(1.6rem, 3vw, 2.5rem); }.progression-latest span { color: var(--subtext-0); font-size: var(--font-size-sm); }.progression-latest em { color: var(--green); font-size: var(--font-size-xs); font-style: normal; } -.progression-chart { width: 100%; height: 8.5rem; min-height: 7rem; margin-top: .7rem; } -.tile-large .progression-chart { height: 12rem; } +.progression-chart { width: 100%; height: 6.5rem; min-height: 6rem; margin-top: .7rem; } +.tile-large .progression-content { height: 100%; display: grid; grid-template-columns: minmax(14rem, .85fr) minmax(18rem, 1.15fr); grid-template-rows: auto auto 1fr auto; column-gap: 1.25rem; align-items: start; } +.tile-large .progression-chart { grid-column: 2; grid-row: 1 / span 4; height: 100%; min-height: 10rem; margin-top: 0; } .chart-loading { min-height: 8rem; display: grid; place-items: center; color: var(--overlay-1); font-size: var(--font-size-xs); } .fact-list { margin: .8rem 0 0; }.fact-list.horizontal { display: flex; flex-wrap: wrap; gap: .75rem 1.2rem; }.fact-list div { min-width: 4rem; }.fact-list dt { color: var(--overlay-1); font: 650 var(--font-size-xs)/1.25 var(--font-mono); letter-spacing: .035em; text-transform: uppercase; }.fact-list dd { margin: .25rem 0 0; color: var(--text); font-size: var(--font-size-md); line-height: 1.3; } .zone-chips { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: .4rem; }.zone-chips span { padding: .38rem .58rem; color: var(--mauve); background: rgb(203 166 247 / 8%); border-radius: 99rem; font-size: var(--font-size-xs); } .record-list, .muscle-list { margin: 0; padding: 0; list-style: none; }.record-list { display: grid; gap: .72rem; }.record-list li { min-width: 0; display: flex; justify-content: space-between; align-items: center; gap: .8rem; }.record-list div { min-width: 0; display: grid; gap: .12rem; }.record-list strong { overflow: hidden; color: var(--text); font-size: var(--font-size-md); text-overflow: ellipsis; white-space: nowrap; }.record-list span { overflow: hidden; color: var(--overlay-1); font-size: var(--font-size-xs); line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }.record-list b { flex: none; color: var(--peach); font-size: 1.125rem; } .partial-note { margin: .8rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); } .muscle-list { display: grid; gap: .58rem; }.muscle-label { min-width: 0; display: flex; justify-content: space-between; gap: 1rem; color: var(--subtext-0); font-size: var(--font-size-sm); line-height: 1.3; }.muscle-label strong { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }.muscle-label span { flex: none; }.muscle-track { height: .3rem; margin-top: .3rem; overflow: hidden; background: var(--surface-0); border-radius: 99rem; }.muscle-track span { display: block; height: 100%; background: var(--mauve); border-radius: inherit; }.muscle-list p { margin: .22rem 0 0; color: var(--overlay-1); font-size: var(--font-size-xs); line-height: 1.3; } -.muscle-content:has(.muscle-visual) { display: grid; grid-template-columns: minmax(12rem, .9fr) minmax(12rem, 1.1fr); gap: 1rem; align-items: start; } +.muscle-content:has(.muscle-visual) { display: grid; grid-template-columns: minmax(12rem, .8fr) minmax(12rem, 1.2fr); gap: 1rem; align-items: start; } +.tile-large .muscle-content:has(.muscle-visual) { grid-template-columns: minmax(18rem, 1.15fr) minmax(16rem, 1.85fr); gap: 1.25rem; } .body-zone-figure { position: relative; min-width: 0; } .body-views { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .45rem; } .body-views figure { min-width: 0; margin: 0; text-align: center; } .body-views figcaption { margin-bottom: .15rem; color: var(--overlay-1); font: 650 var(--font-size-xs) var(--font-mono); text-transform: uppercase; letter-spacing: .06em; } .body-views svg { display: block; width: 100%; height: 10.5rem; overflow: visible; } -.tile-large .body-views svg { height: 13.5rem; } +.tile-large .body-views svg { height: 16.5rem; } .body-outline { fill: var(--surface-0); stroke: var(--surface-2); stroke-width: 1.2; } .body-region { stroke: var(--base); stroke-width: 1.4; vector-effect: non-scaling-stroke; transition: fill 140ms ease, filter 140ms ease; } .body-region.intensity-0, .muscle-legend .intensity-0 { fill: var(--surface-0); background: var(--surface-0); } @@ -173,6 +177,8 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .principle-panel { min-height: 17rem; } .layout-toolbar { justify-content: flex-start; } .layout-help { flex-basis: 100%; } + .tile-large .progression-content { height: auto; display: block; } + .tile-large .progression-chart { height: 6.5rem; min-height: 6rem; margin-top: .7rem; } .muscle-content:has(.muscle-visual) { grid-template-columns: minmax(0, 1fr); } .body-views svg, .tile-large .body-views svg { height: 11rem; } .status-footer { grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; }