fix(web): complete dashboard v1 final review
This commit is contained in:
parent
86af874de5
commit
c04fa76f0b
9 changed files with 120 additions and 13 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
28
docs/reviews/web_dashboard_v1.md
Normal file
28
docs/reviews/web_dashboard_v1.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue