fix(web): complete dashboard v1 final review

This commit is contained in:
fy59 2026-09-16 21:26:43 +02:00
parent 86af874de5
commit c04fa76f0b
9 changed files with 120 additions and 13 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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:

View file

@ -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')

View file

@ -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<string, number>()
@ -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 } },
},

View file

@ -84,6 +84,7 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
rerender(<MuscleDistributionTile snapshot={snapshot} size="large" />)
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', () => {

View file

@ -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; }