diff --git a/docs/architecture.md b/docs/architecture.md index ec1ad46..4152a31 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: diff --git a/docs/current_state.md b/docs/current_state.md index 13f1079..2b57df9 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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. diff --git a/docs/design/web_dashboard_visualizations_v1.md b/docs/design/web_dashboard_visualizations_v1.md new file mode 100644 index 0000000..9b20ce1 --- /dev/null +++ b/docs/design/web_dashboard_visualizations_v1.md @@ -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`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 508d031..619e341 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -32,7 +32,7 @@ tile rendering exist. Advanced visualizations remain a future slice. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_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 diff --git a/docs/tests.md b/docs/tests.md index d9d77e9..10d8757 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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: diff --git a/tui/meson.build b/tui/meson.build index 73f88b6..cbe5ff9 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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', diff --git a/web/package-lock.json b/web/package-lock.json index 0a104a4..24c8dd6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" + } } } } diff --git a/web/package.json b/web/package.json index 187417d..14fec23 100644 --- a/web/package.json +++ b/web/package.json @@ -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" diff --git a/web/src/charts/ProgressionChart.test.tsx b/web/src/charts/ProgressionChart.test.tsx new file mode 100644 index 0000000..6c01a6d --- /dev/null +++ b/web/src/charts/ProgressionChart.test.tsx @@ -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() + expect(screen.getByRole('img')).toHaveAccessibleName(/1 mesure réelle/) + rerender() + expect(screen.getByRole('img')).toHaveAccessibleName(/2 mesures réelles/) + }) +}) diff --git a/web/src/charts/ProgressionChart.tsx b/web/src/charts/ProgressionChart.tsx new file mode 100644 index 0000000..0303223 --- /dev/null +++ b/web/src/charts/ProgressionChart.tsx @@ -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() + keys.forEach((key) => totals.set(key, (totals.get(key) ?? 0) + 1)) + const seen = new Map() + 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(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
+} diff --git a/web/src/charts/trainlogChartTheme.ts b/web/src/charts/trainlogChartTheme.ts new file mode 100644 index 0000000..fb33110 --- /dev/null +++ b/web/src/charts/trainlogChartTheme.ts @@ -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, + } +} diff --git a/web/src/dashboard/BodyZoneFigure.test.tsx b/web/src/dashboard/BodyZoneFigure.test.tsx new file mode 100644 index 0000000..78c1602 --- /dev/null +++ b/web/src/dashboard/BodyZoneFigure.test.tsx @@ -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() + 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() + 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() + const before = container.querySelector('#front-chest-left')?.getAttribute('class') + rerender() + expect(container.querySelector('#front-chest-left')).toHaveAttribute('class', before) + }) +}) diff --git a/web/src/dashboard/BodyZoneFigure.tsx b/web/src/dashboard/BodyZoneFigure.tsx new file mode 100644 index 0000000..354d716 --- /dev/null +++ b/web/src/dashboard/BodyZoneFigure.tsx @@ -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(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
+
+ + {views === 'both' && } +
+
0FaibleIntermédiaireMaximum observé
+ {focused &&
{focused.label}{count(focused.session_count, 'séance')} · {count(focused.occurrence_count, 'occurrence')} · {count(focused.set_count, 'série')}
} +
+} + +type RegionProps = (zoneId: MappedBodyZoneId) => Record + +function BodyView({ title, side, zoneProps }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps }) { + return
{title}
+ + + {side === 'front' ? <> + + + + + + + : <> + + + + + + + } +
+} diff --git a/web/src/dashboard/DashboardGrid.test.tsx b/web/src/dashboard/DashboardGrid.test.tsx index cbbc1b7..9775d82 100644 --- a/web/src/dashboard/DashboardGrid.test.tsx +++ b/web/src/dashboard/DashboardGrid.test.tsx @@ -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() - 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') }) }) diff --git a/web/src/dashboard/DashboardTiles.test.tsx b/web/src/dashboard/DashboardTiles.test.tsx index d861f8c..eeb7eb5 100644 --- a/web/src/dashboard/DashboardTiles.test.tsx +++ b/web/src/dashboard/DashboardTiles.test.tsx @@ -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() + expect(screen.queryByTestId('progression-chart')).not.toBeInTheDocument() + rerender() + 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() + 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() @@ -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() + expect(screen.queryByLabelText('Silhouette, vue avant')).not.toBeInTheDocument() + rerender() + expect(screen.getByLabelText('Silhouette, vue avant')).toBeInTheDocument() + expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument() + expect(screen.getByText('Corps entier')).toBeInTheDocument() + rerender() + 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' } diff --git a/web/src/dashboard/DashboardTiles.tsx b/web/src/dashboard/DashboardTiles.tsx index 134f54b..cd060a1 100644 --- a/web/src/dashboard/DashboardTiles.tsx +++ b/web/src/dashboard/DashboardTiles.tsx @@ -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) {

{progression.identity.exercise_name}

{size !== 'compact' &&

{progression.identity.equipment_label}

}
{last === undefined ?

Aucun point disponible

:
{formatWeight(last.weight_kg)}{formatDate(last.timestamp)}{last.improved && Amélioration enregistrée}
} {size !== 'compact' &&
} + {size !== 'compact' && progression.points.length > 0 && <>Chargement de la courbe…
}>
    {progression.points.map((point, index) =>
  1. {formatDateTime(point.timestamp)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}
  2. )}
} {size === 'large' &&

Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}

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

Longueur : nombre de séances sur 30 jours

    {shown.map((zone) => )}
+ return
+ {size !== 'compact' &&

Couleur : séances sur 30 jours · maximum relatif au snapshot affiché

} +

Longueur : nombre de séances sur 30 jours

    {shown.map((zone) => )}
+
} function MuscleRow({ zone, maximum, detailed, full }: { zone: WorkedZone; maximum: number; detailed: boolean; full: boolean }) { diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index cd796f8..9433af1 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -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() diff --git a/web/src/theme/app.css b/web/src/theme/app.css index eb27032..c5f5b56 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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; }