feat(web): add dashboard visualizations

This commit is contained in:
fy59 2026-09-16 21:09:01 +02:00
parent f290286122
commit 86af874de5
18 changed files with 509 additions and 8 deletions

View file

@ -318,6 +318,26 @@ rules. The activity map encodes active/session facts directly; muscle bar
length is exactly `session_count`, explicitly labelled. Responsive projection
changes density, not the canonical layout or metric semantics.
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` keeps visualization behind the
same typed snapshot. Progression dynamically imports modular ECharts
(`LineChart`, `GridComponent`, `TooltipComponent`, `SVGRenderer`) only in
medium/large sizes. One instance is owned per mounted chart, updated from the
exact comparable series, resized by `ResizeObserver`, and disposed on unmount.
SVG was selected over Canvas because the bounded series has very few elements
and vector sharpness/responsive scaling matter more than high-volume drawing.
All colours are read from the live Catppuccin CSS variables.
The muscle figure is an original code-native SVG. Canonical localized mappings
are: `chest` to bilateral front chest; `back` to rear upper back and bilateral
lats; `shoulders` to bilateral front/rear shoulders; `arms` to bilateral
front/rear arms; `core` to the front trunk; `glutes` to bilateral rear glutes;
`thighs` and `calves` to bilateral front/rear legs. Aggregate zones
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
remain in the complete text list. Visual levels are a deterministic ratio to
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_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

@ -45,6 +45,7 @@ parallel implementation of its rules.
| Web Dashboard grid | `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` |
| 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` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
@ -213,6 +214,13 @@ only reveal existing facts. Activity uses a factual CSS day map, MAX lists are
bounded 1/3/8, and muscle bars encode `session_count` only. Unavailable,
partial, invalid and transport-error states never synthesize values.
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache
ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real
point remains visible, `smooth=false`, legacy zeroes are preserved, and only
Core-owned `improved` flags alter markers. An original React SVG supplies
front/back BODY ZONES regions whose discrete colour level depends solely on
`session_count`; the factual text list remains authoritative and accessible.
`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
@ -225,9 +233,9 @@ smoke test are not automated. The latter is why
## Active limitations
- `WEB_DASHBOARD_VISUALIZATIONS_V1`: advanced curves and muscle visualization
remain future work; Tile V1 intentionally uses bounded text, CSS and direct
factual encodings without ECharts.
- `WEB_DASHBOARD_V1_FINAL_REVIEW`: global visual consistency, runtime
performance and remaining placeholder decisions await the final bounded
Dashboard V1 review.
- `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,51 @@
# Web Dashboard Visualizations V1
Status: `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN`
## ECharts boundary
Trainlog pins Apache ECharts 6.1.0 under Apache-2.0. Only `LineChart`,
`GridComponent`, `TooltipComponent` and `SVGRenderer` are registered from the
tree-shakable API. The chart module is dynamically imported only when a
medium/large available Progression tile needs it. SVG is appropriate for this
small series, stays sharp through Grid resize, and avoids a default ECharts
visual identity. Options derive from the live Catppuccin CSS tokens.
The x axis contains only supplied timestamps. The y axis contains absolute
`weight_kg`, uses a data-driven value scale, and is visibly labelled `kg`.
Every supplied point is shown, including legacy `0.0 kg`; the unsmoothed line
is only a visual connection between observations. Marker emphasis uses only
the Core-provided `improved` flag. Tooltip facts are date, weight, exercise,
equipment, dose and optional `Amélioration`; no delta or percentage exists.
## Original BODY ZONES SVG
The silhouette is original code-native Trainlog work, with a stable
`0 0 120 260` viewBox and no external asset. Regions map as follows:
| Canonical zone | SVG regions |
|---|---|
| `chest` | bilateral front chest |
| `back` | upper rear back and bilateral rear lats |
| `shoulders` | bilateral front and rear shoulders |
| `arms` | bilateral front and rear arms |
| `core` | front abdominal/trunk region |
| `glutes` | bilateral rear glutes |
| `thighs` | bilateral front and rear thighs |
| `calves` | bilateral front and rear calves |
| `full_body` | no localized V1 region; text list only |
| `upper_body` | no localized V1 region; text list only |
| `lower_body` | no localized V1 region; text list only |
Only a zone present in the snapshot is interactive. Focus/hover exposes its
supplied sessions, occurrences and sets, while the full textual list remains
available. Colour depends solely on `session_count / maximum_session_count` in
the displayed snapshot: zero uses Surface0, `(0, 1/3]` Sapphire, `(1/3, 2/3]`
Lavender and `(2/3, 1]` Mauve. This is a relative visual scale, not a score or
physiological assessment.
Compact tiles remain textual. Medium and large tiles show the figure; large
allocates more vertical space. Activity keeps its factual CSS heatmap, while
Next Session, Last Session, MAX and Cardio receive no decorative chart.
Next cursor: `WEB_DASHBOARD_V1_FINAL_REVIEW`.

View file

@ -32,7 +32,7 @@ tile rendering exist. Advanced visualizations remain a future slice.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_VISUALIZATIONS_V1
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_V1_FINAL_REVIEW
```
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
@ -48,6 +48,9 @@ layout with atomic XDG storage, optimistic revisions and protected HTTP
mutations; responsive projections remain derived.
`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` replaces every shell placeholder with a
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.
## TRAINLOG_WEB_V1 implementation gate
@ -82,7 +85,8 @@ WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_TILES_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_VISUALIZATIONS_V1
-> WEB_DASHBOARD_VISUALIZATIONS_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_V1_FINAL_REVIEW
```
The slice order may be refined by the next contract, but it must preserve the

View file

@ -93,6 +93,14 @@ legacy 0 kg visibility, `improved` use, unfinished-session duration absence,
global loading/error/invalid signals, accessible textual equivalents and live
density changes during keyboard resize.
`WEB_DASHBOARD_VISUALIZATIONS_V1` adds Vitest coverage for lazy chart presence
by tile size, factual progression tooltips, one/multiple/legacy-zero points,
accessible series descriptions, the complete canonical BODY ZONES SVG mapping,
unmapped aggregate preservation, deterministic session-only intensity levels,
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.
## 2. Frozen Trainlog JSON v1
Run:

View file

@ -54,7 +54,12 @@ if web_assets_enabled
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/Tile.tsx',
'../web/src/charts/ProgressionChart.tsx',
'../web/src/charts/trainlogChartTheme.ts',
'../web/src/dashboard/BodyZoneFigure.tsx',
'../web/src/dashboard/DashboardGrid.tsx',
'../web/src/dashboard/DashboardTiles.tsx',
'../web/src/dashboard/dashboardFormat.ts',
'../web/src/dashboard/dashboardLayout.ts',
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx',

26
web/package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "trainlog-web",
"version": "0.0.0",
"dependencies": {
"echarts": "6.1.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-grid-layout": "2.2.4"
@ -1302,6 +1303,16 @@
"license": "MIT",
"peer": true
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
@ -2230,6 +2241,12 @@
"node": ">=20"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@ -2517,6 +2534,15 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}

View file

@ -9,6 +9,7 @@
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"echarts": "6.1.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-grid-layout": "2.2.4"

View file

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { dashboardFixture } from '../test/dashboardFixtures'
import { ProgressionChart, progressionCollisionMetadata, progressionTooltip } from './ProgressionChart'
describe('courbe de progression factuelle', () => {
it('construit un tooltip exclusivement depuis le point et son identité', () => {
const progression = dashboardFixture().data.progression
if (!progression.available) throw new Error('fixture invalide')
const normal = progressionTooltip(progression.identity, progression.points[0])
expect(normal).toContain('0 kg')
expect(normal).not.toContain('Amélioration')
expect(normal).not.toContain('%')
const improved = progressionTooltip(progression.identity, progression.points[1])
expect(improved).toContain('18,5 kg')
expect(improved).toContain('Amélioration')
expect(improved).toContain('Dose : 10')
})
it('rend explicites les mesures superposées sans modifier leurs coordonnées', () => {
const progression = dashboardFixture().data.progression
if (!progression.available) throw new Error('fixture invalide')
const duplicate = { ...progression.points[0] }
expect(progressionCollisionMetadata([progression.points[0], duplicate, progression.points[1]])).toEqual([
{ count: 2, index: 0 }, { count: 2, index: 1 }, { count: 1, index: 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')
const { rerender } = render(<ProgressionChart identity={progression.identity} points={progression.points.slice(0, 1)} />)
expect(screen.getByRole('img')).toHaveAccessibleName(/1 mesure réelle/)
rerender(<ProgressionChart identity={progression.identity} points={progression.points} />)
expect(screen.getByRole('img')).toHaveAccessibleName(/2 mesures réelles/)
})
})

View file

@ -0,0 +1,98 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import type { ProgressionIdentity, ProgressionPoint } from '../api/dashboard'
import { formatDateTime, formatDose, formatWeight } from '../dashboard/dashboardFormat'
import { trainlogChartTheme } from './trainlogChartTheme'
echarts.use([LineChart, GridComponent, TooltipComponent, SVGRenderer])
interface ProgressionChartProps { identity: ProgressionIdentity; points: ProgressionPoint[] }
interface ChartDatum {
value: [number, number]
point: ProgressionPoint
itemStyle: { color: string; borderColor: string; borderWidth: number }
symbolSize: number
label?: { show: boolean; formatter: string; position: 'top'; color: string; fontSize: number }
}
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>()
keys.forEach((key) => totals.set(key, (totals.get(key) ?? 0) + 1))
const seen = new Map<string, number>()
return keys.map((key) => {
const index = seen.get(key) ?? 0
seen.set(key, index + 1)
return { count: totals.get(key) ?? 1, index }
})
}
export function progressionTooltip(identity: ProgressionIdentity, point: ProgressionPoint): string {
return [
formatDateTime(point.timestamp),
formatWeight(point.weight_kg),
identity.exercise_name,
identity.equipment_label,
`Dose : ${formatDose(identity.dose)}`,
...(point.improved ? ['Amélioration'] : []),
].filter(Boolean).join('\n')
}
export function ProgressionChart({ identity, points }: ProgressionChartProps) {
const host = useRef<HTMLDivElement>(null)
useEffect(() => {
if (host.current === null) return
const element = host.current
const theme = trainlogChartTheme(element)
const chart = echarts.init(element, undefined, { renderer: 'svg' })
const collisions = progressionCollisionMetadata(points)
const data: ChartDatum[] = points.map((point, index) => {
const collision = collisions[index]
const baseSize = point.improved ? 10 : 7
return {
value: [new Date(point.timestamp).valueOf(), point.weight_kg], point,
itemStyle: { color: point.improved ? theme.green : theme.lavender, borderColor: theme.base, borderWidth: 2 },
symbolSize: baseSize + (collision.count - collision.index - 1) * 4,
...(collision.count > 1 && collision.index === collision.count - 1 ? { label: { show: true, formatter: `×${collision.count}`, position: 'top' as const, color: theme.subtext, fontSize: 12 } } : {}),
}
})
chart.setOption({
animation: !matchMedia('(prefers-reduced-motion: reduce)').matches,
textStyle: { color: theme.text, fontFamily: theme.fontSans, fontSize: theme.fontSize },
grid: { left: 8, right: 10, top: 12, bottom: 4, outerBoundsMode: 'same' },
tooltip: {
trigger: 'item', renderMode: 'richText', confine: true,
backgroundColor: theme.mantle, borderColor: theme.surface2, textStyle: { color: theme.text, fontFamily: theme.fontSans, fontSize: 12, lineHeight: 18 },
formatter: (params: unknown) => {
const candidate = Array.isArray(params) ? params[0] : params
const datum = (candidate as { data?: ChartDatum } | undefined)?.data
return datum === undefined ? '' : progressionTooltip(identity, datum.point)
},
},
xAxis: {
type: 'time', boundaryGap: false,
axisLine: { lineStyle: { color: theme.surface2 } }, axisTick: { show: false },
axisLabel: { color: theme.subtext, fontSize: 12, hideOverlap: true }, splitLine: { show: false },
},
yAxis: {
type: 'value', name: 'kg', 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 } },
},
series: [{
type: 'line', data, smooth: false, showSymbol: true, connectNulls: false,
lineStyle: { color: theme.lavender, width: 2 }, emphasis: { focus: 'series', scale: 1.15 },
}],
})
const observer = new ResizeObserver(() => chart.resize())
observer.observe(element)
return () => { observer.disconnect(); chart.dispose() }
}, [identity, points])
return <div className="progression-chart" ref={host} data-testid="progression-chart" role="img" aria-label={`Courbe de ${identity.exercise_name} : ${points.length} ${points.length === 1 ? 'mesure réelle' : 'mesures réelles'} en kilogrammes. La ligne relie les observations sans représenter de mesures intermédiaires.`} />
}

View file

@ -0,0 +1,46 @@
export interface TrainlogChartTheme {
base: string
mantle: string
surface0: string
surface1: string
surface2: string
overlay1: string
text: string
subtext: string
lavender: string
mauve: string
blue: string
sapphire: string
green: string
yellow: string
peach: string
red: string
fontSans: string
fontSize: number
}
const fallback: TrainlogChartTheme = {
base: '#1e1e2e', mantle: '#181825', surface0: '#313244', surface1: '#45475a',
surface2: '#585b70', overlay1: '#7f849c', text: '#cdd6f4', subtext: '#a6adc8',
lavender: '#b4befe', mauve: '#cba6f7', blue: '#89b4fa', sapphire: '#74c7ec',
green: '#a6e3a1', yellow: '#f9e2af', peach: '#fab387', red: '#f38ba8',
fontSans: 'Inter, ui-sans-serif, system-ui, sans-serif', fontSize: 12,
}
/** CONTRACT: charts consume the Web design system at runtime, so a theme
* change cannot silently leave ECharts with its default palette. */
export function trainlogChartTheme(element: Element): TrainlogChartTheme {
const style = getComputedStyle(element)
const value = (token: string, defaultValue: string) => style.getPropertyValue(token).trim() || defaultValue
return {
base: value('--base', fallback.base), mantle: value('--mantle', fallback.mantle),
surface0: value('--surface-0', fallback.surface0), surface1: value('--surface-1', fallback.surface1),
surface2: value('--surface-2', fallback.surface2), overlay1: value('--overlay-1', fallback.overlay1),
text: value('--text', fallback.text), subtext: value('--subtext-0', fallback.subtext),
lavender: value('--lavender', fallback.lavender), mauve: value('--mauve', fallback.mauve),
blue: value('--blue', fallback.blue), sapphire: value('--sapphire', fallback.sapphire),
green: value('--green', fallback.green), yellow: value('--yellow', fallback.yellow),
peach: value('--peach', fallback.peach), red: value('--red', fallback.red),
fontSans: value('--font-sans', fallback.fontSans), fontSize: 12,
}
}

View file

@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { dashboardFixture } from '../test/dashboardFixtures'
import { BODY_ZONE_SVG_MAP, BodyZoneFigure, muscleIntensityLevel, UNMAPPED_BODY_ZONE_IDS } from './BodyZoneFigure'
describe('silhouette BODY ZONES canonique', () => {
it('mappe uniquement les huit zones anatomiquement localisables et documente les agrégats', () => {
expect(Object.keys(BODY_ZONE_SVG_MAP)).toEqual(['chest', 'back', 'shoulders', 'arms', 'core', 'glutes', 'thighs', 'calves'])
expect(UNMAPPED_BODY_ZONE_IDS).toEqual(['full_body', 'upper_body', 'lower_body'])
expect(new Set(Object.values(BODY_ZONE_SVG_MAP).flat()).size).toBe(Object.values(BODY_ZONE_SVG_MAP).flat().length)
})
it('détermine la couleur uniquement depuis session_count et le maximum observé', () => {
expect(muscleIntensityLevel(0, 4)).toBe(0)
expect(muscleIntensityLevel(1, 4)).toBe(1)
expect(muscleIntensityLevel(2, 4)).toBe(2)
expect(muscleIntensityLevel(4, 4)).toBe(3)
expect(muscleIntensityLevel(2, 4)).toBe(muscleIntensityLevel(2, 4))
})
it('rend avant/arrière, focus clavier et tooltip factuel sans remplacer la liste', () => {
const zones = dashboardFixture().data.muscle_distribution.primary_zones
const { container } = render(<BodyZoneFigure zones={zones} />)
expect(screen.getByLabelText('Silhouette, vue avant')).toBeInTheDocument()
expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument()
const chest = container.querySelector('#front-chest-left') as SVGElement
expect(chest).toHaveAttribute('tabindex', '0')
expect(chest).toHaveAccessibleName('Pectoraux : 4 séances, 6 occurrences, 18 séries.')
fireEvent.focus(chest)
expect(screen.getByRole('status')).toHaveTextContent('Pectoraux4 séances · 6 occurrences · 18 séries')
expect(container.querySelector('#back-upper')).toHaveClass('intensity-3')
})
it('rend une région absente neutre et non interactive', () => {
const zone = dashboardFixture().data.muscle_distribution.primary_zones[0]
const { container } = render(<BodyZoneFigure zones={[zone]} />)
expect(container.querySelector('#front-calf-left')).toHaveClass('intensity-0')
expect(container.querySelector('#front-calf-left')).toHaveAttribute('aria-hidden', 'true')
expect(container.querySelector('#front-calf-left')).not.toHaveAttribute('tabindex')
})
it('ignore occurrences et séries lors du choix de couleur', () => {
const base = { zone_id: 'chest', label: 'Pectoraux', session_count: 2, occurrence_count: 1, set_count: 1 }
const { container, rerender } = render(<BodyZoneFigure zones={[base]} />)
const before = container.querySelector('#front-chest-left')?.getAttribute('class')
rerender(<BodyZoneFigure zones={[{ ...base, occurrence_count: 999, set_count: 999 }]} />)
expect(container.querySelector('#front-chest-left')).toHaveAttribute('class', before)
})
})

View file

@ -0,0 +1,77 @@
import { useState } from 'react'
import type { WorkedZone } from '../api/dashboard'
import { count } from './dashboardFormat'
export const BODY_ZONE_SVG_MAP = {
chest: ['front-chest-left', 'front-chest-right'],
back: ['back-upper', 'back-lat-left', 'back-lat-right'],
shoulders: ['front-shoulder-left', 'front-shoulder-right', 'back-shoulder-left', 'back-shoulder-right'],
arms: ['front-arm-left', 'front-arm-right', 'back-arm-left', 'back-arm-right'],
core: ['front-core'],
glutes: ['back-glute-left', 'back-glute-right'],
thighs: ['front-thigh-left', 'front-thigh-right', 'back-thigh-left', 'back-thigh-right'],
calves: ['front-calf-left', 'front-calf-right', 'back-calf-left', 'back-calf-right'],
} as const
export type MappedBodyZoneId = keyof typeof BODY_ZONE_SVG_MAP
export const UNMAPPED_BODY_ZONE_IDS = ['full_body', 'upper_body', 'lower_body'] as const
export function muscleIntensityLevel(sessionCount: number, maximum: number): 0 | 1 | 2 | 3 {
if (sessionCount <= 0 || maximum <= 0) return 0
const ratio = sessionCount / maximum
if (ratio <= 1 / 3) return 1
if (ratio <= 2 / 3) return 2
return 3
}
interface BodyZoneFigureProps { zones: WorkedZone[]; views?: 'front' | 'both' }
export function BodyZoneFigure({ zones, views = 'both' }: BodyZoneFigureProps) {
const [focused, setFocused] = useState<WorkedZone | null>(null)
const byId = new Map(zones.map((zone) => [zone.zone_id, zone]))
const maximum = Math.max(1, ...zones.map((zone) => zone.session_count))
const zoneProps = (zoneId: MappedBodyZoneId) => {
const zone = byId.get(zoneId)
if (zone === undefined) return { className: 'body-region intensity-0', 'aria-hidden': true as const }
return {
className: `body-region intensity-${muscleIntensityLevel(zone.session_count, maximum)}`,
tabIndex: 0,
role: 'button',
'aria-label': `${zone.label} : ${count(zone.session_count, 'séance')}, ${count(zone.occurrence_count, 'occurrence')}, ${count(zone.set_count, 'série')}.`,
onFocus: () => setFocused(zone), onBlur: () => setFocused(null),
onMouseEnter: () => setFocused(zone), onMouseLeave: () => setFocused(null),
}
}
return <div className="body-zone-figure">
<div className="body-views">
<BodyView title="Avant" side="front" zoneProps={zoneProps} />
{views === 'both' && <BodyView title="Arrière" side="back" zoneProps={zoneProps} />}
</div>
<div className="muscle-legend" aria-label="Échelle visuelle des séances sur 30 jours"><span className="intensity-0" />0<span className="intensity-1" />Faible<span className="intensity-2" />Intermédiaire<span className="intensity-3" />Maximum observé</div>
{focused && <div className="body-tooltip" role="status"><strong>{focused.label}</strong><span>{count(focused.session_count, 'séance')} · {count(focused.occurrence_count, 'occurrence')} · {count(focused.set_count, 'série')}</span></div>}
</div>
}
type RegionProps = (zoneId: MappedBodyZoneId) => Record<string, unknown>
function BodyView({ title, side, zoneProps }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps }) {
return <figure><figcaption>{title}</figcaption><svg viewBox="0 0 120 260" role="group" aria-label={`Silhouette, vue ${title.toLowerCase()}`}>
<circle className="body-outline" cx="60" cy="23" r="16" />
<path className="body-outline" d="M42 43 Q60 36 78 43 L88 116 75 150 70 244H52L45 150 32 116Z" />
{side === 'front' ? <>
<ellipse id="front-shoulder-left" {...zoneProps('shoulders')} cx="37" cy="57" rx="10" ry="12" /><ellipse id="front-shoulder-right" {...zoneProps('shoulders')} cx="83" cy="57" rx="10" ry="12" />
<path id="front-chest-left" {...zoneProps('chest')} d="M43 58 Q52 53 58 59 L57 85 Q47 85 42 77Z" /><path id="front-chest-right" {...zoneProps('chest')} d="M77 58 Q68 53 62 59 L63 85 Q73 85 78 77Z" />
<path id="front-arm-left" {...zoneProps('arms')} d="M28 61 Q35 58 41 64 L34 130 23 128Z" /><path id="front-arm-right" {...zoneProps('arms')} d="M92 61 Q85 58 79 64 L86 130 97 128Z" />
<path id="front-core" {...zoneProps('core')} d="M46 88 Q60 94 74 88 L76 135 Q60 145 44 135Z" />
<path id="front-thigh-left" {...zoneProps('thighs')} d="M45 139 Q54 143 59 140 L56 198 43 198Z" /><path id="front-thigh-right" {...zoneProps('thighs')} d="M75 139 Q66 143 61 140 L64 198 77 198Z" />
<path id="front-calf-left" {...zoneProps('calves')} d="M43 201H56L54 247H46Z" /><path id="front-calf-right" {...zoneProps('calves')} d="M64 201H77L74 247H66Z" />
</> : <>
<ellipse id="back-shoulder-left" {...zoneProps('shoulders')} cx="37" cy="57" rx="10" ry="12" /><ellipse id="back-shoulder-right" {...zoneProps('shoulders')} cx="83" cy="57" rx="10" ry="12" />
<path id="back-upper" {...zoneProps('back')} d="M43 57 Q60 48 77 57 L73 83 Q60 91 47 83Z" /><path id="back-lat-left" {...zoneProps('back')} d="M43 82L58 89 55 127 43 135Z" /><path id="back-lat-right" {...zoneProps('back')} d="M77 82L62 89 65 127 77 135Z" />
<path id="back-arm-left" {...zoneProps('arms')} d="M28 61 Q35 58 41 64 L34 130 23 128Z" /><path id="back-arm-right" {...zoneProps('arms')} d="M92 61 Q85 58 79 64 L86 130 97 128Z" />
<path id="back-glute-left" {...zoneProps('glutes')} d="M44 132 Q53 127 59 136 L57 157 Q47 160 42 151Z" /><path id="back-glute-right" {...zoneProps('glutes')} d="M76 132 Q67 127 61 136 L63 157 Q73 160 78 151Z" />
<path id="back-thigh-left" {...zoneProps('thighs')} d="M43 159Q52 162 58 158L56 201H43Z" /><path id="back-thigh-right" {...zoneProps('thighs')} d="M77 159Q68 162 62 158L64 201H77Z" />
<path id="back-calf-left" {...zoneProps('calves')} d="M43 203H56L54 247H46Z" /><path id="back-calf-right" {...zoneProps('calves')} d="M64 203H77L74 247H66Z" />
</>}
</svg></figure>
}

View file

@ -126,6 +126,6 @@ describe('grille interactive du Dashboard', () => {
expect(screen.getByRole('alert')).toHaveTextContent('momentanément indisponibles')
const snapshot = dashboardFixture(); snapshot.meta.invalid_data = true
rerender(<DashboardGrid dashboard={snapshot} />)
expect(screen.getByRole('status')).toHaveTextContent('données invalides ont été écartées')
expect(screen.getByText('Certaines données invalides ont été écartées.')).toHaveAttribute('role', 'status')
})
})

View file

@ -30,6 +30,21 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
expect(screen.queryByText(/%/)).not.toBeInTheDocument()
})
it('réserve la courbe aux tailles medium et large', async () => {
const snapshot = dashboardFixture()
const { rerender } = render(<ProgressionTile snapshot={snapshot} size="compact" />)
expect(screen.queryByTestId('progression-chart')).not.toBeInTheDocument()
rerender(<ProgressionTile snapshot={snapshot} size="medium" />)
expect(await screen.findByTestId('progression-chart', undefined, { timeout: 5_000 })).toBeInTheDocument()
expect(screen.getByRole('list', { name: 'Mesures de progression' })).toHaveTextContent('0 kg')
expect(screen.getByRole('list', { name: 'Mesures de progression' })).toHaveTextContent('amélioration')
rerender(<ProgressionTile snapshot={snapshot} size="large" />)
expect(await screen.findByTestId('progression-chart', undefined, { timeout: 5_000 })).toBeInTheDocument()
expect(screen.getByText('Premier')).toBeInTheDocument()
expect(screen.getByText('Dernier')).toBeInTheDocument()
expect(screen.getByText('Points')).toBeInTheDocument()
})
it('omet toute durée fabriquée lorsque ended_at est absent et révèle les zones en grand', () => {
const snapshot = dashboardFixture()
const { rerender } = render(<LastSessionTile snapshot={snapshot} size="medium" />)
@ -57,6 +72,20 @@ describe('tuiles Dashboard alimentées par le contrat', () => {
expect(screen.getByText(/Longueur : nombre de séances/)).toBeInTheDocument()
})
it('affiche la silhouette en medium/large, jamais en compact, et conserve une zone non mappée dans la liste', () => {
const snapshot = dashboardFixture()
snapshot.data.muscle_distribution.primary_zones.push({ zone_id: 'full_body', label: 'Corps entier', session_count: 1, occurrence_count: 1, set_count: 0 })
const { rerender } = render(<MuscleDistributionTile snapshot={snapshot} size="compact" />)
expect(screen.queryByLabelText('Silhouette, vue avant')).not.toBeInTheDocument()
rerender(<MuscleDistributionTile snapshot={snapshot} size="medium" />)
expect(screen.getByLabelText('Silhouette, vue avant')).toBeInTheDocument()
expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument()
expect(screen.getByText('Corps entier')).toBeInTheDocument()
rerender(<MuscleDistributionTile snapshot={snapshot} size="large" />)
expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument()
expect(screen.getByText(/Couleur : séances sur 30 jours/)).toBeInTheDocument()
})
it('gère les états indisponibles des domaines optionnels', () => {
const snapshot = dashboardFixture()
snapshot.data.progression = { available: false, reason: 'no_comparable_performance' }

View file

@ -1,7 +1,10 @@
import type { DashboardSnapshot, WorkedZone } from '../api/dashboard'
import type { ComponentType } from 'react'
import { lazy, Suspense, type ComponentType } from 'react'
import type { TileSize } from './dashboardLayout'
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
import { BodyZoneFigure } from './BodyZoneFigure'
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize }
@ -35,6 +38,7 @@ export function ProgressionTile({ snapshot, size }: TileDataProps) {
<div><p className="primary-label">{progression.identity.exercise_name}</p>{size !== 'compact' && <p className="secondary-label">{progression.identity.equipment_label}</p>}</div>
{last === undefined ? <p className="muted">Aucun point disponible</p> : <div className="progression-latest"><strong>{formatWeight(last.weight_kg)}</strong><span>{formatDate(last.timestamp)}</span>{last.improved && <em>Amélioration enregistrée</em>}</div>}
{size !== 'compact' && <dl className="fact-list horizontal"><Fact label="Dose" value={formatDose(progression.identity.dose)} /><Fact label="Premier" value={first ? formatWeight(first.weight_kg) : '—'} /><Fact label="Dernier" value={last ? formatWeight(last.weight_kg) : '—'} /><Fact label="Points" value={String(progression.points.length)} /></dl>}
{size !== 'compact' && progression.points.length > 0 && <><Suspense fallback={<div className="chart-loading" role="status">Chargement de la courbe…</div>}><ProgressionChart identity={progression.identity} points={progression.points} /></Suspense><ol className="sr-only" aria-label="Mesures de progression">{progression.points.map((point, index) => <li key={`${point.session_id}-${point.timestamp}-${index}`}>{formatDateTime(point.timestamp)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}</li>)}</ol></>}
{size === 'large' && <p className="chart-legend">Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}</p>}
</div>
}
@ -64,7 +68,10 @@ export function MuscleDistributionTile({ snapshot, size }: TileDataProps) {
const ordered = [...zones].sort((a, b) => b.session_count - a.session_count || b.occurrence_count - a.occurrence_count || a.label.localeCompare(b.label, 'fr'))
const shown = size === 'compact' ? ordered.slice(0, 3) : ordered
const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1)
return <div className="muscle-content"><p className="chart-legend">Longueur : nombre de séances sur 30 jours</p><ul className="muscle-list">{shown.map((zone) => <MuscleRow key={zone.zone_id} zone={zone} maximum={maximum} detailed={size !== 'compact'} full={size === 'large'} />)}</ul></div>
return <div className="muscle-content">
{size !== 'compact' && <div className="muscle-visual"><BodyZoneFigure zones={zones} /><p className="chart-legend">Couleur : séances sur 30 jours · maximum relatif au snapshot affiché</p></div>}
<div className="muscle-details"><p className="chart-legend">Longueur : nombre de séances sur 30 jours</p><ul className="muscle-list">{shown.map((zone) => <MuscleRow key={zone.zone_id} zone={zone} maximum={maximum} detailed={size !== 'compact'} full={size === 'large'} />)}</ul></div>
</div>
}
function MuscleRow({ zone, maximum, detailed, full }: { zone: WorkedZone; maximum: number; detailed: boolean; full: boolean }) {

View file

@ -4,6 +4,18 @@ import { cleanup } from '@testing-library/react'
Object.defineProperty(window, 'scrollTo', { value: vi.fn(), writable: true })
Object.defineProperty(window, 'innerWidth', { value: 1440, writable: true })
Object.defineProperty(window, 'matchMedia', { value: vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), writable: true })
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
Object.defineProperty(window, 'ResizeObserver', { value: TestResizeObserver, writable: true })
Object.defineProperty(globalThis, 'ResizeObserver', { value: TestResizeObserver, writable: true })
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: vi.fn(() => ({ measureText: (text: string) => ({ width: text.length * 7 }) })),
writable: true,
})
afterEach(() => {
cleanup()

View file

@ -108,11 +108,32 @@ 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; }
.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; }
.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; }
.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); }
.body-region.intensity-1, .muscle-legend .intensity-1 { fill: var(--sapphire); background: var(--sapphire); opacity: .62; }
.body-region.intensity-2, .muscle-legend .intensity-2 { fill: var(--lavender); background: var(--lavender); opacity: .82; }
.body-region.intensity-3, .muscle-legend .intensity-3 { fill: var(--mauve); background: var(--mauve); }
.body-region[tabindex]:hover, .body-region[tabindex]:focus { outline: none; filter: brightness(1.18) drop-shadow(0 0 .18rem rgb(180 190 254 / 45%)); stroke: var(--text); }
.muscle-legend { margin-top: .35rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .25rem .35rem; color: var(--overlay-1); font-size: var(--font-size-xs); line-height: 1.3; }
.muscle-legend span { width: .65rem; height: .65rem; border-radius: .15rem; }
.body-tooltip { position: absolute; z-index: 2; right: .25rem; bottom: 2.2rem; max-width: calc(100% - .5rem); padding: .5rem .65rem; display: grid; gap: .16rem; color: var(--text); background: rgb(24 24 37 / 96%); border: 1px solid var(--surface-2); border-radius: .45rem; box-shadow: 0 .6rem 1.5rem rgb(0 0 0 / 28%); font-size: var(--font-size-xs); pointer-events: none; }
.body-tooltip span { color: var(--subtext-0); }
.muted { color: var(--subtext-0); }
.tile-compact .tile-content { margin-top: .65rem; }.tile-compact .tile-header { gap: .5rem; }.tile-compact .tile-state { padding: .3rem .42rem; }.tile-compact .activity-heatmap { grid-template-rows: repeat(5, .62rem); }.tile-compact .metric-row { margin-bottom: .65rem; }.tile-compact .progression-latest { margin-bottom: 0; }
.empty-state { max-width: 22rem; color: var(--subtext-0); text-align: center; line-height: 1.5; }
@ -152,6 +173,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%; }
.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; }
.status-zone { grid-column: 1 / -1; }
.backend-state { grid-column: 1 / -1; justify-self: start; }