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 length is exactly `session_count`, explicitly labelled. Responsive projection
changes density, not the canonical layout or metric semantics. 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 `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN` fixes one coherent, read-only
`GET /api/v1/dashboard` snapshot. One outer SQLite read savepoint covers every `GET /api/v1/dashboard` snapshot. One outer SQLite read savepoint covers every
projection; statements are finalized before return. The seven domains mean: 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 grid | `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` |
| Web Dashboard layout | `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` | | Web Dashboard layout | `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` |
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_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` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen 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, bounded 1/3/8, and muscle bars encode `session_count` only. Unavailable,
partial, invalid and transport-error states never synthesize values. 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, `TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus formatting, layout-invariance and source-derived text-boundary tests, plus
Android resource-parity, language-owner, typed sync-presentation, and Android resource-parity, language-owner, typed sync-presentation, and
@ -225,9 +233,9 @@ smoke test are not automated. The latter is why
## Active limitations ## Active limitations
- `WEB_DASHBOARD_VISUALIZATIONS_V1`: advanced curves and muscle visualization - `WEB_DASHBOARD_V1_FINAL_REVIEW`: global visual consistency, runtime
remain future work; Tile V1 intentionally uses bounded text, CSS and direct performance and remaining placeholder decisions await the final bounded
factual encodings without ECharts. Dashboard V1 review.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review. - `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered - AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test. 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 ## Current cursor
```text ```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, `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. mutations; responsive projections remain derived.
`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` replaces every shell placeholder with a `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` replaces every shell placeholder with a
size-adaptive component consuming only the frozen Dashboard snapshot. 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 ## TRAINLOG_WEB_V1 implementation gate
@ -82,7 +85,8 @@ WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN] -> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN] -> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_TILES_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 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 global loading/error/invalid signals, accessible textual equivalents and live
density changes during keyboard resize. 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 ## 2. Frozen Trainlog JSON v1
Run: Run:

View file

@ -54,7 +54,12 @@ if web_assets_enabled
'../web/src/components/Footer.tsx', '../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx', '../web/src/components/Header.tsx',
'../web/src/components/Tile.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/DashboardGrid.tsx',
'../web/src/dashboard/DashboardTiles.tsx',
'../web/src/dashboard/dashboardFormat.ts',
'../web/src/dashboard/dashboardLayout.ts', '../web/src/dashboard/dashboardLayout.ts',
'../web/src/routes/DashboardPage.tsx', '../web/src/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx', '../web/src/routes/PlaceholderPage.tsx',

26
web/package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "trainlog-web", "name": "trainlog-web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"echarts": "6.1.0",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"react-grid-layout": "2.2.4" "react-grid-layout": "2.2.4"
@ -1302,6 +1303,16 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/entities": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
@ -2230,6 +2241,12 @@
"node": ">=20" "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": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@ -2517,6 +2534,15 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true, "dev": true,
"license": "MIT" "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" "typecheck": "tsc -b --pretty false"
}, },
"dependencies": { "dependencies": {
"echarts": "6.1.0",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
"react-grid-layout": "2.2.4" "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') expect(screen.getByRole('alert')).toHaveTextContent('momentanément indisponibles')
const snapshot = dashboardFixture(); snapshot.meta.invalid_data = true const snapshot = dashboardFixture(); snapshot.meta.invalid_data = true
rerender(<DashboardGrid dashboard={snapshot} />) 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() 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', () => { it('omet toute durée fabriquée lorsque ended_at est absent et révèle les zones en grand', () => {
const snapshot = dashboardFixture() const snapshot = dashboardFixture()
const { rerender } = render(<LastSessionTile snapshot={snapshot} size="medium" />) 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() 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', () => { it('gère les états indisponibles des domaines optionnels', () => {
const snapshot = dashboardFixture() const snapshot = dashboardFixture()
snapshot.data.progression = { available: false, reason: 'no_comparable_performance' } snapshot.data.progression = { available: false, reason: 'no_comparable_performance' }

View file

@ -1,7 +1,10 @@
import type { DashboardSnapshot, WorkedZone } from '../api/dashboard' 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 type { TileSize } from './dashboardLayout'
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat' 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 } 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> <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>} {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' && <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>} {size === 'large' && <p className="chart-legend">Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}</p>}
</div> </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 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 shown = size === 'compact' ? ordered.slice(0, 3) : ordered
const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1) 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 }) { 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, 'scrollTo', { value: vi.fn(), writable: true })
Object.defineProperty(window, 'innerWidth', { value: 1440, 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(() => { afterEach(() => {
cleanup() 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; } .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; } .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-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; } .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); } .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; } .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); } .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-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); } .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; } .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; } .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; } .principle-panel { min-height: 17rem; }
.layout-toolbar { justify-content: flex-start; } .layout-toolbar { justify-content: flex-start; }
.layout-help { flex-basis: 100%; } .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-footer { grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; }
.status-zone { grid-column: 1 / -1; } .status-zone { grid-column: 1 / -1; }
.backend-state { grid-column: 1 / -1; justify-self: start; } .backend-state { grid-column: 1 / -1; justify-self: start; }