diff --git a/docs/architecture.md b/docs/architecture.md index 447c2a5..ec1ad46 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -309,6 +309,15 @@ If-Match guard optimistic revisions; mutation additionally requires exact loopback or `trainlog.perf` Origin and an ephemeral startup-random CSRF header. Invalid files are preserved for diagnosis while GET safely returns the default. +`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` keeps the HTTP boundary singular: `App` +loads and strictly validates one Dashboard snapshot, then supplies that same +immutable business view to the Footer and the grid. Tile components perform +presentation-only selection by logical size and direct sums of the 90 daily +counts. They never reconstruct activity, progression, session, MAX or zone +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_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 e68b3c0..13f1079 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -44,6 +44,7 @@ parallel implementation of its rules. | Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` | | 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` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | Desktop and Android schema numbers are independent. Neither changes the frozen @@ -206,6 +207,12 @@ under the private XDG configuration directory, backend revalidation, atomic protection, and durable React load/save/reset behavior. Only the desktop 12-column canon persists. +`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` renders all seven domains from the one +validated `/api/v1/dashboard` snapshot. Compact, medium and large densities +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. + `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 @@ -218,12 +225,9 @@ smoke test are not automated. The latter is why ## Active limitations -- `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN`: the bounded Core snapshot, - `/api/v1/dashboard`, explicit unavailable states and factual Footer data are - implemented. The seven tile bodies intentionally retain shell placeholders. -- `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN`: versioned disk/API persistence is - complete. The seven tile bodies remain placeholders for - `WEB_DASHBOARD_TILES_V1`. +- `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. - `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_tiles_v1.md b/docs/design/web_dashboard_tiles_v1.md new file mode 100644 index 0000000..3f00e21 --- /dev/null +++ b/docs/design/web_dashboard_tiles_v1.md @@ -0,0 +1,39 @@ +# Web Dashboard Tiles V1 + +Status: `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` + +## Boundary + +All seven tile components consume the single validated +`TrainlogWebDashboardSnapshot` returned by `GET /api/v1/dashboard`. React may +format values, select a bounded subset for the current logical size, and sum +the already projected daily counters. It does not derive business truth. + +## Density contract + +`compact` exposes the essential fact, `medium` adds context, and `large` +exposes all useful bounded fields. Resize never changes a metric. Responsive +layouts reuse the same rule and never write a second layout truth. + +## Factual representations + +- Prochaine séance and Cardio render explicit unavailable states without + exposing backend reason identifiers or synthesizing values. +- Activité maps each supplied day to inactive, active-one-session, or + active-multiple-sessions CSS classes. The colour is not physiological + intensity. Totals are direct sums of `session_count` and `set_count`. +- Progression shows only the selected comparable identity, supplied points and + Core-owned `improved` flags. Legacy `0.0 kg` remains visible. +- Dernière séance exposes supplied timestamps and counts. A null duration stays + unavailable; primary zones are the Core projection. +- Records / MAX shows 1, 3 or at most 8 explicit records. A partial snapshot is + labelled as recent rather than exhaustive. +- Répartition musculaire sorts the supplied primary zones deterministically + for display. Bar length represents only `session_count`; occurrences and + sets stay separate textual facts. + +The global shell reports loading/transport failure once. `invalid_data` is a +discrete global warning; valid surviving projections remain usable. No +ECharts, inferred score, demo datum or production fixture is part of V1. + +Next cursor: `WEB_DASHBOARD_VISUALIZATIONS_V1`. diff --git a/docs/roadmap.md b/docs/roadmap.md index a0de87f..508d031 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,13 +26,13 @@ implemented. Session Generator V1 is hidden pending V2. AI session-draft exchange and APP_SHELL_V1 retain their explicit manual validation/review gates. `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data -contract, frozen interactive grid and versioned layout persistence exist. Final -tile rendering remains a future slice. +contract, frozen interactive grid, versioned layout persistence and factual +tile rendering exist. Advanced visualizations remain a future slice. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_TILES_V1 +CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_VISUALIZATIONS_V1 ``` `WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React, @@ -46,6 +46,8 @@ projections without persisting layout state. `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` persists only that canonical desktop 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. ## TRAINLOG_WEB_V1 implementation gate @@ -79,8 +81,8 @@ must consume them without reconstructing or redefining their mathematics. WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN] -> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN] -> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN] - -> real-data tile delivery - -> real-data visualization delivery + -> WEB_DASHBOARD_TILES_V1 [PASS/FROZEN] + -> WEB_DASHBOARD_VISUALIZATIONS_V1 ``` 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 1e9c3cd..d9d77e9 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -85,6 +85,14 @@ clients, CSRF/Origin rejection and `trainlog.perf` Origin acceptance. Vitest covers default/persisted/invalid sources, ETag/token parsing, canonical-only PUT, conflict translation, loading, local cancel/reset and durable Save. +`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed +field and Vitest fixtures confined to test sources. Coverage proves the 90-day +activity map and direct totals, all compact/medium/large disclosure levels, +legacy 0 kg visibility, `improved` use, unfinished-session duration absence, +1/3/8 MAX bounds, factual muscle bars, unavailable cardio/next-session states, +global loading/error/invalid signals, accessible textual equivalents and live +density changes during keyboard resize. + ## 2. Frozen Trainlog JSON v1 Run: diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts index c6db49c..4a507f2 100644 --- a/web/src/api/dashboard.ts +++ b/web/src/api/dashboard.ts @@ -1,51 +1,60 @@ -export interface DashboardFooter { - user: string - last_session_date: string | null - last_zones: string[] -} +export interface DashboardFooter { user: string; last_session_date: string | null; last_zones: string[] } +export interface ActivityDay { date: string; active: boolean; session_count: number; set_count: number } +export interface WorkedZone { zone_id: string; label: string; session_count: number; occurrence_count: number; set_count: number } +export interface ProgressionIdentity { exercise_id: string; exercise_name: string; equipment_id: string; equipment_label: string; tracking_mode: string; load_mode: string; dose: number } +export interface ProgressionPoint { session_id: string; timestamp: string; metric_value: number; weight_kg: number; improved: boolean } +export interface MaxRecord { session_id: string; entry_id: string; exercise_id: string; exercise_name: string; equipment_id: string; weight_kg: number; timestamp: string } +type Unavailable = { available: false; reason: string } +export type Progression = Unavailable | { available: true; identity: ProgressionIdentity; points: ProgressionPoint[] } +export type LastSession = Unavailable | { available: true; session_id: string; started_at: string; ended_at: string | null; duration_seconds: number | null; exercise_count: number; set_count: number; continuous_count: number; max_count: number; primary_zones: WorkedZone[] } export interface DashboardSnapshot { api_version: 1 data: { footer: DashboardFooter - next_session: { available: false; reason: string } - cardio: { available: false; reason: string } - activity: { available: true; window_days: 90; days: unknown[] } - progression: { available: boolean; reason?: string } - last_session: { available: boolean; reason?: string } - max_records: { available: boolean; records: unknown[] } - muscle_distribution: { available: boolean; window_days: 30; primary_zones: unknown[] } + next_session: Unavailable + cardio: Unavailable + activity: { available: true; window_days: 90; days: ActivityDay[] } + progression: Progression + last_session: LastSession + max_records: { available: boolean; records: MaxRecord[] } + muscle_distribution: { available: boolean; window_days: 30; primary_zones: WorkedZone[] } } meta: { partial: boolean; invalid_data: boolean; generated_at: string } } -const record = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) -const nonNegativeInteger = (value: unknown): value is number => - typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 -const workedZone = (value: unknown) => record(value) && - typeof value.zone_id === 'string' && typeof value.label === 'string' && - nonNegativeInteger(value.session_count) && nonNegativeInteger(value.occurrence_count) && - nonNegativeInteger(value.set_count) +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const finiteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) +const nonNegativeInteger = (value: unknown): value is number => finiteNumber(value) && Number.isSafeInteger(value) && value >= 0 +const nullableString = (value: unknown) => value === null || typeof value === 'string' +const workedZone = (value: unknown) => record(value) && typeof value.zone_id === 'string' && typeof value.label === 'string' && nonNegativeInteger(value.session_count) && nonNegativeInteger(value.occurrence_count) && nonNegativeInteger(value.set_count) +function validProgression(value: unknown): boolean { + if (!record(value) || typeof value.available !== 'boolean') return false + if (!value.available) return typeof value.reason === 'string' + if (!record(value.identity) || !Array.isArray(value.points)) return false + const identity = value.identity + return typeof identity.exercise_id === 'string' && typeof identity.exercise_name === 'string' && typeof identity.equipment_id === 'string' && typeof identity.equipment_label === 'string' && typeof identity.tracking_mode === 'string' && typeof identity.load_mode === 'string' && finiteNumber(identity.dose) && value.points.every((point) => record(point) && typeof point.session_id === 'string' && typeof point.timestamp === 'string' && finiteNumber(point.metric_value) && finiteNumber(point.weight_kg) && typeof point.improved === 'boolean') +} + +function validLastSession(value: unknown): boolean { + if (!record(value) || typeof value.available !== 'boolean') return false + if (!value.available) return typeof value.reason === 'string' + return typeof value.session_id === 'string' && typeof value.started_at === 'string' && nullableString(value.ended_at) && (value.duration_seconds === null || nonNegativeInteger(value.duration_seconds)) && nonNegativeInteger(value.exercise_count) && nonNegativeInteger(value.set_count) && nonNegativeInteger(value.continuous_count) && nonNegativeInteger(value.max_count) && Array.isArray(value.primary_zones) && value.primary_zones.every(workedZone) +} + +/** CONTRACT: a snapshot is rejected when a tile field has an unexpected type; + * components never infer missing business facts. */ export function parseDashboard(value: unknown): DashboardSnapshot { if (!record(value) || value.api_version !== 1 || !record(value.data) || !record(value.meta)) throw new TypeError('dashboard invalide') - const data = value.data - const footer = data.footer - const next = data.next_session - const cardio = data.cardio - const activity = data.activity - const progression = data.progression - const last = data.last_session - const maxima = data.max_records - const muscles = data.muscle_distribution - if (!record(footer) || typeof footer.user !== 'string' || !(footer.last_session_date === null || typeof footer.last_session_date === 'string') || !Array.isArray(footer.last_zones) || !footer.last_zones.every((zone) => typeof zone === 'string')) throw new TypeError('footer invalide') + const data = value.data, footer = data.footer, next = data.next_session, cardio = data.cardio, activity = data.activity, maxima = data.max_records, muscles = data.muscle_distribution + if (!record(footer) || typeof footer.user !== 'string' || !nullableString(footer.last_session_date) || !Array.isArray(footer.last_zones) || !footer.last_zones.every((zone) => typeof zone === 'string')) throw new TypeError('footer invalide') if (!record(next) || next.available !== false || typeof next.reason !== 'string') throw new TypeError('prochaine séance invalide') if (!record(cardio) || cardio.available !== false || typeof cardio.reason !== 'string') throw new TypeError('cardio invalide') if (!record(activity) || activity.available !== true || activity.window_days !== 90 || !Array.isArray(activity.days) || activity.days.length !== 90 || !activity.days.every((day) => record(day) && typeof day.date === 'string' && typeof day.active === 'boolean' && nonNegativeInteger(day.session_count) && nonNegativeInteger(day.set_count))) throw new TypeError('activité invalide') - if (!record(progression) || typeof progression.available !== 'boolean' || (progression.available === false && typeof progression.reason !== 'string') || (progression.available === true && (!record(progression.identity) || !Array.isArray(progression.points)))) throw new TypeError('progression invalide') - if (!record(last) || typeof last.available !== 'boolean' || (last.available === false && typeof last.reason !== 'string') || (last.available === true && (typeof last.session_id !== 'string' || typeof last.started_at !== 'string' || !Array.isArray(last.primary_zones) || !last.primary_zones.every(workedZone)))) throw new TypeError('dernière séance invalide') - if (!record(maxima) || typeof maxima.available !== 'boolean' || !Array.isArray(maxima.records) || !maxima.records.every((item) => record(item) && typeof item.session_id === 'string' && typeof item.entry_id === 'string' && typeof item.exercise_id === 'string' && typeof item.exercise_name === 'string' && typeof item.equipment_id === 'string' && typeof item.weight_kg === 'number' && Number.isFinite(item.weight_kg) && typeof item.timestamp === 'string')) throw new TypeError('MAX invalide') + if (!validProgression(data.progression)) throw new TypeError('progression invalide') + if (!validLastSession(data.last_session)) throw new TypeError('dernière séance invalide') + if (!record(maxima) || typeof maxima.available !== 'boolean' || !Array.isArray(maxima.records) || !maxima.records.every((item) => record(item) && typeof item.session_id === 'string' && typeof item.entry_id === 'string' && typeof item.exercise_id === 'string' && typeof item.exercise_name === 'string' && typeof item.equipment_id === 'string' && finiteNumber(item.weight_kg) && typeof item.timestamp === 'string')) throw new TypeError('MAX invalide') if (!record(muscles) || typeof muscles.available !== 'boolean' || muscles.window_days !== 30 || !Array.isArray(muscles.primary_zones) || !muscles.primary_zones.every(workedZone)) throw new TypeError('zones invalides') if (typeof value.meta.partial !== 'boolean' || typeof value.meta.invalid_data !== 'boolean' || typeof value.meta.generated_at !== 'string') throw new TypeError('meta invalide') return value as unknown as DashboardSnapshot diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx index 7a7cd5e..1f11163 100644 --- a/web/src/app/App.test.tsx +++ b/web/src/app/App.test.tsx @@ -63,7 +63,7 @@ describe('shell Trainlog', () => { expect(footer.parentElement).toHaveClass('app-shell') expect(screen.getByRole('link', { name: 'Trainlog — Dashboard' })).toHaveTextContent('TRAINLOG') expect(screen.queryByText('Vos repères d’entraînement, sans extrapolation ni donnée inventée.')).not.toBeInTheDocument() - await waitFor(() => expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7)) + await waitFor(() => expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(6)) expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—') expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible') expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible') diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 6379d44..76225aa 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -21,6 +21,8 @@ export function App() { const [healthPending, setHealthPending] = useState(true) const [healthFailed, setHealthFailed] = useState(false) const [dashboard, setDashboard] = useState(null) + const [dashboardPending, setDashboardPending] = useState(true) + const [dashboardFailed, setDashboardFailed] = useState(false) useEffect(() => { const controller = new AbortController() @@ -28,11 +30,15 @@ export function App() { setHealth(value) setHealthFailed(false) }).catch(() => setHealthFailed(true)).finally(() => setHealthPending(false)) - fetchDashboard(controller.signal).then(setDashboard).catch(() => setDashboard(null)) + fetchDashboard(controller.signal).then((value) => { + setDashboard(value); setDashboardFailed(false) + }).catch(() => { + setDashboard(null); setDashboardFailed(true) + }).finally(() => setDashboardPending(false)) return () => controller.abort() }, []) - const content = route.id === 'dashboard' ? : + const content = route.id === 'dashboard' ? : diff --git a/web/src/components/Tile.tsx b/web/src/components/Tile.tsx index f46b74a..72d2d1a 100644 --- a/web/src/components/Tile.tsx +++ b/web/src/components/Tile.tsx @@ -9,9 +9,11 @@ interface TileProps { size?: TileSize editable?: boolean onKeyDown?: KeyboardEventHandler + stateLabel?: string + stateTone?: 'available' | 'unavailable' | 'partial' } -export function Tile({ title, eyebrow, children, className = '', size = 'medium', editable = false, onKeyDown }: TileProps) { +export function Tile({ title, eyebrow, children, className = '', size = 'medium', editable = false, onKeyDown, stateLabel = 'DISPONIBLE', stateTone = 'available' }: TileProps) { return (
{eyebrow}

}

{title}

- INDISPONIBLE + {stateLabel}
{children}
diff --git a/web/src/dashboard/DashboardGrid.test.tsx b/web/src/dashboard/DashboardGrid.test.tsx index ad1d518..cbbc1b7 100644 --- a/web/src/dashboard/DashboardGrid.test.tsx +++ b/web/src/dashboard/DashboardGrid.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DashboardGrid } from './DashboardGrid' import { DEFAULT_DASHBOARD_LAYOUT } from './dashboardLayout' +import { dashboardFixture } from '../test/dashboardFixtures' beforeEach(() => { vi.stubGlobal('fetch', vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { @@ -104,4 +105,27 @@ describe('grille interactive du Dashboard', () => { expect(fetchMock).toHaveBeenCalledTimes(1) window.innerWidth = 1440 }) + + it('rend les faits réels et adapte immédiatement la densité pendant le resize', async () => { + const snapshot = dashboardFixture() + render() + fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' })) + expect(screen.getByText('Identité comparable : reps · external')).toBeInTheDocument() + fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowLeft', shiftKey: true }) + expect(screen.queryByText('Identité comparable : reps · external')).not.toBeInTheDocument() + expect(screen.getAllByText(/^Exercice \d$/)).toHaveLength(1) + fireEvent.keyDown(screen.getByRole('article', { name: 'Records / MAX, tuile modifiable' }), { key: 'ArrowDown', shiftKey: true }) + expect(screen.getAllByText(/^Exercice \d$/)).toHaveLength(3) + }) + + it('signale discrètement loading, erreur globale et invalid_data', async () => { + const { rerender } = render() + await screen.findByRole('button', { name: 'Modifier l’agencement' }) + expect(screen.getByRole('status')).toHaveTextContent('Chargement des données') + rerender() + 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') + }) }) diff --git a/web/src/dashboard/DashboardGrid.tsx b/web/src/dashboard/DashboardGrid.tsx index b95f48e..025c9b5 100644 --- a/web/src/dashboard/DashboardGrid.tsx +++ b/web/src/dashboard/DashboardGrid.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout' -import { EmptyState } from '../components/EmptyState' import { Tile } from '../components/Tile' +import type { DashboardSnapshot } from '../api/dashboard' import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout' import { cloneLayout, @@ -17,15 +17,16 @@ import { type TileId, type TileLayout, } from './dashboardLayout' +import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles' -const dashboardTiles: Readonly> = { - 'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', message: 'Aucune séance préparée disponible.' }, - activity: { title: 'Activité', eyebrow: 'Régularité', message: 'Les données d’activité ne sont pas encore exposées.' }, - progression: { title: 'Progression', eyebrow: 'Évolution', message: 'La métrique de progression reste à définir.' }, - 'last-session': { title: 'Dernière séance', eyebrow: 'Historique', message: 'Aucune séance terminée disponible.' }, - 'max-records': { title: 'Records / MAX', eyebrow: 'Performances', message: 'Aucun MAX mesuré disponible.' }, - 'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', message: 'La répartition par zone n’est pas encore disponible.' }, - 'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', message: 'Aucune donnée cardio ou récupération disponible.' }, +const dashboardTiles: Readonly> = { + 'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile }, + activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile }, + progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile }, + 'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile }, + 'max-records': { title: 'Records / MAX', eyebrow: 'Performances', component: MaxRecordsTile }, + 'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile }, + 'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', component: CardioTile }, } function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } { @@ -34,7 +35,9 @@ function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } { return { columns: 12, rowHeight: 52 } } -export function DashboardGrid() { +interface DashboardGridProps { dashboard?: DashboardSnapshot | null; pending?: boolean; failed?: boolean } + +export function DashboardGrid({ dashboard = null, pending = false, failed = false }: DashboardGridProps) { const [layout, setLayout] = useState(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) const [editing, setEditing] = useState(false) const [announcement, setAnnouncement] = useState('') @@ -147,6 +150,9 @@ export function DashboardGrid() { )} {preferenceError &&
{preferenceError}{preferenceError.includes('autre onglet') && }
} + {pending &&
Chargement des données du Dashboard…
} + {failed &&
Les données du Dashboard sont momentanément indisponibles.
} + {dashboard?.meta.invalid_data &&
Certaines données invalides ont été écartées.
}

{announcement}

{ const content = dashboardTiles[id] const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0] + const size = tileSize(dimensions) + const available = dashboard === null ? false : tileAvailable(dashboard, id) + const partial = dashboard?.meta.partial === true && id === 'max-records' + const TileContent = content.component return (
onTileKeyDown(event, id)} + stateLabel={pending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'} + stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'} > - + {dashboard === null ? : }
) @@ -181,3 +193,13 @@ export function DashboardGrid() {
) } + +function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean { + if (id === 'next-session') return snapshot.data.next_session.available + if (id === 'activity') return snapshot.data.activity.available + if (id === 'progression') return snapshot.data.progression.available + if (id === 'last-session') return snapshot.data.last_session.available + if (id === 'max-records') return snapshot.data.max_records.available + if (id === 'muscle-distribution') return snapshot.data.muscle_distribution.available + return snapshot.data.cardio.available +} diff --git a/web/src/dashboard/DashboardTiles.test.tsx b/web/src/dashboard/DashboardTiles.test.tsx new file mode 100644 index 0000000..d861f8c --- /dev/null +++ b/web/src/dashboard/DashboardTiles.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { dashboardFixture } from '../test/dashboardFixtures' +import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile } from './DashboardTiles' + +describe('tuiles Dashboard alimentées par le contrat', () => { + it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => { + const snapshot = dashboardFixture() + const { rerender } = render() + expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument() + expect(document.body).not.toHaveTextContent('no_persisted_executable_plan') + rerender() + expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument() + expect(document.body).not.toHaveTextContent('no_cardio_data_source') + expect(document.body).not.toHaveTextContent(/bpm|%/i) + }) + + it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => { + const { container } = render() + const heatmap = screen.getByRole('img') + expect(heatmap).toHaveAccessibleName(/2 jours actifs, 3 séances, 17 séries/) + expect(container.querySelectorAll('.activity-day')).toHaveLength(size === 'compact' ? 35 : 90) + }) + + it('présente l’identité comparable, le flag improved et le point legacy 0 kg', () => { + render() + expect(screen.getByText('Presse épaules')).toBeInTheDocument() + expect(screen.getByText('Amélioration enregistrée')).toBeInTheDocument() + expect(screen.getByText('0 kg')).toBeInTheDocument() + expect(screen.queryByText(/%/)).not.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() + expect(screen.getByText('Indisponible')).toBeInTheDocument() + if (!snapshot.data.last_session.available) throw new Error('fixture invalide') + snapshot.data.last_session.duration_seconds = 3720 + rerender() + expect(screen.getByText('1 h 02')).toBeInTheDocument() + expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Pectoraux') + expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Épaules') + }) + + it.each([['compact', 1], ['medium', 3], ['large', 8]] as const)('borne les MAX en taille %s à %i', (size, expected) => { + const { container } = render() + expect(container.querySelectorAll('.record-list li')).toHaveLength(expected) + if (size === 'large') expect(screen.getByText('8 MAX récents affichés')).toBeInTheDocument() + }) + + it('encode les barres musculaires avec les séances et conserve les valeurs textuelles', () => { + const { container } = render() + const chest = within(container.querySelector('.muscle-list li') as HTMLElement) + expect(chest.getByText('4 séances')).toBeInTheDocument() + expect(chest.getByText(/6 occurrences · 18 séries/)).toBeInTheDocument() + expect((container.querySelector('.muscle-track span') as HTMLElement).style.width).toBe('100%') + expect(screen.getByText(/Longueur : nombre de séances/)).toBeInTheDocument() + }) + + it('gère les états indisponibles des domaines optionnels', () => { + const snapshot = dashboardFixture() + snapshot.data.progression = { available: false, reason: 'no_comparable_performance' } + snapshot.data.last_session = { available: false, reason: 'no_observable_session' } + snapshot.data.max_records = { available: false, records: [] } + snapshot.data.muscle_distribution = { available: false, window_days: 30, primary_zones: [] } + const { rerender } = render() + expect(screen.getByText(/Aucune performance/)).toBeInTheDocument() + rerender(); expect(screen.getByText(/Aucune séance observable/)).toBeInTheDocument() + rerender(); expect(screen.getByText(/Aucun MAX explicite/)).toBeInTheDocument() + rerender(); expect(screen.getByText(/Aucune zone travaillée/)).toBeInTheDocument() + }) +}) diff --git a/web/src/dashboard/DashboardTiles.tsx b/web/src/dashboard/DashboardTiles.tsx new file mode 100644 index 0000000..134f54b --- /dev/null +++ b/web/src/dashboard/DashboardTiles.tsx @@ -0,0 +1,82 @@ +import type { DashboardSnapshot, WorkedZone } from '../api/dashboard' +import type { ComponentType } from 'react' +import type { TileSize } from './dashboardLayout' +import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat' + +interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize } + +export function NextSessionTile({ size }: TileDataProps) { + return

Aucune séance préparée

{size !== 'compact' && Aucune séance préparée n’est actuellement disponible.}{size === 'large' && }
+} + +export function ActivityTile({ snapshot, size }: TileDataProps) { + const days = snapshot.data.activity.days + // CONTRACT: these are direct presentation totals of Core-projected counters, + // never an activity or physiological-intensity score. + const activeDays = days.filter((day) => day.active).length + const sessions = days.reduce((total, day) => total + day.session_count, 0) + const sets = days.reduce((total, day) => total + day.set_count, 0) + const shown = size === 'compact' ? days.slice(-35) : days + return
+
{size !== 'compact' && }{size === 'large' && }
+
+ {shown.map((day) => 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)} +
+ {size === 'large' &&

Jour avec au moins une séance · la teinte indique uniquement le nombre de séances

} +
+} + +export function ProgressionTile({ snapshot, size }: TileDataProps) { + const progression = snapshot.data.progression + if (!progression.available) return + const first = progression.points[0] + const last = progression.points.at(-1) + return
+

{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 === 'large' &&

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

} +
+} + +export function LastSessionTile({ snapshot, size }: TileDataProps) { + const session = snapshot.data.last_session + if (!session.available) return + return
+

{size === 'compact' ? formatDate(session.started_at) : formatDateTime(session.started_at)}

+
{size !== 'compact' && <>}
+ {size === 'large' &&
{session.primary_zones.map((zone) => {zone.label})}
} +
+} + +export function MaxRecordsTile({ snapshot, size }: TileDataProps) { + const records = snapshot.data.max_records.records + if (!snapshot.data.max_records.available || records.length === 0) return + const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8 + return
    {records.slice(0, limit).map((record) =>
  1. {record.exercise_name}{formatDate(record.timestamp)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}
    {formatWeight(record.weight_kg)}
  2. )}
{snapshot.meta.partial && records.length >= 8 &&

8 MAX récents affichés

}
+} + +export function MuscleDistributionTile({ snapshot, size }: TileDataProps) { + const zones = snapshot.data.muscle_distribution.primary_zones + if (!snapshot.data.muscle_distribution.available || zones.length === 0) return + // INVARIANT: deterministic display order is separate from bar semantics; + // each bar length represents session_count alone. + 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) => )}
+} + +function MuscleRow({ zone, maximum, detailed, full }: { zone: WorkedZone; maximum: number; detailed: boolean; full: boolean }) { + return
  • {zone.label}{count(zone.session_count, 'séance')}
    {detailed &&

    {count(zone.occurrence_count, 'occurrence')}{full && ` · ${count(zone.set_count, 'série')}`}

    }
  • +} + +export function CardioTile({ size }: TileDataProps) { + return
    {size !== 'compact' &&

    Aucune donnée cardio disponible

    }{size === 'large' && Les mesures apparaîtront ici lorsqu’une source cardio sera enregistrée.}
    +} + +function Metric({ value, label }: { value: string; label: string }) { return
    {value}{label}
    } +function Fact({ label, value }: { label: string; value: string }) { return
    {label}
    {value}
    } +function Unavailable({ text }: { text: string }) { return

    {text}

    } + +export type DashboardTileComponent = ComponentType diff --git a/web/src/dashboard/dashboardFormat.ts b/web/src/dashboard/dashboardFormat.ts new file mode 100644 index 0000000..502a988 --- /dev/null +++ b/web/src/dashboard/dashboardFormat.ts @@ -0,0 +1,22 @@ +const dateFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }) +const dateTimeFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) +const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 }) + +function parsed(value: string): Date | null { + const result = new Date(value) + return Number.isNaN(result.valueOf()) ? null : result +} + +export const formatDate = (value: string) => { const date = parsed(value); return date === null ? value : dateFormat.format(date) } +export const formatDateTime = (value: string) => { const date = parsed(value); return date === null ? value : dateTimeFormat.format(date) } +export const formatWeight = (value: number) => `${numberFormat.format(value)} kg` +export const formatDose = (value: number) => numberFormat.format(value) +export function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const remaining = seconds % 60 + if (hours > 0) return `${hours} h ${minutes.toString().padStart(2, '0')}` + if (minutes > 0) return `${minutes} min${remaining ? ` ${remaining} s` : ''}` + return `${remaining} s` +} +export const count = (value: number, singular: string, plural = `${singular}s`) => `${value} ${value === 1 ? singular : plural}` diff --git a/web/src/routes/DashboardPage.tsx b/web/src/routes/DashboardPage.tsx index 897c4b7..3d7d4ae 100644 --- a/web/src/routes/DashboardPage.tsx +++ b/web/src/routes/DashboardPage.tsx @@ -1,12 +1,15 @@ import { DashboardGrid } from '../dashboard/DashboardGrid' +import type { DashboardSnapshot } from '../api/dashboard' -export function DashboardPage() { +interface DashboardPageProps { dashboard: DashboardSnapshot | null; pending: boolean; failed: boolean } + +export function DashboardPage({ dashboard, pending, failed }: DashboardPageProps) { return (

    VUE D’ENSEMBLE

    Dashboard

    - +
    ) } diff --git a/web/src/test/dashboardFixtures.ts b/web/src/test/dashboardFixtures.ts new file mode 100644 index 0000000..62755cc --- /dev/null +++ b/web/src/test/dashboardFixtures.ts @@ -0,0 +1,40 @@ +import type { DashboardSnapshot } from '../api/dashboard' + +export function dashboardFixture(): DashboardSnapshot { + const start = Date.UTC(2026, 5, 19) + const days = Array.from({ length: 90 }, (_, index) => ({ + date: new Date(start + index * 86_400_000).toISOString().slice(0, 10), + active: index === 87 || index === 89, + session_count: index === 87 ? 2 : index === 89 ? 1 : 0, + set_count: index === 87 ? 12 : index === 89 ? 5 : 0, + })) + return { + api_version: 1, + data: { + footer: { user: 'test', last_session_date: '2026-09-16', last_zones: ['Pectoraux', 'Épaules'] }, + next_session: { available: false, reason: 'no_persisted_executable_plan' }, + activity: { available: true, window_days: 90, days }, + progression: { + available: true, + identity: { exercise_id: 'ex-1', exercise_name: 'Presse épaules', equipment_id: 'machine-1', equipment_label: 'Presse convergente', tracking_mode: 'reps', load_mode: 'external', dose: 10 }, + points: [ + { session_id: 's-1', timestamp: '2026-09-10T08:00:00+02:00', metric_value: 10, weight_kg: 0, improved: false }, + { session_id: 's-2', timestamp: '2026-09-16T08:00:00+02:00', metric_value: 10, weight_kg: 18.5, improved: true }, + ], + }, + last_session: { available: true, session_id: 's-2', started_at: '2026-09-16T08:00:00+02:00', ended_at: null, duration_seconds: null, exercise_count: 6, set_count: 12, continuous_count: 2, max_count: 1, primary_zones: [ + { zone_id: 'chest', label: 'Pectoraux', session_count: 1, occurrence_count: 2, set_count: 6 }, + { zone_id: 'shoulders', label: 'Épaules', session_count: 1, occurrence_count: 2, set_count: 6 }, + ] }, + max_records: { available: true, records: Array.from({ length: 8 }, (_, index) => ({ session_id: 's-1', entry_id: `m-${index}`, exercise_id: `ex-${index}`, exercise_name: `Exercice ${index + 1}`, equipment_id: `machine-${index}`, weight_kg: 40 + index, timestamp: `2026-09-${String(16 - index).padStart(2, '0')}T08:00:00+02:00` })) }, + muscle_distribution: { available: true, window_days: 30, primary_zones: [ + { zone_id: 'chest', label: 'Pectoraux', session_count: 4, occurrence_count: 6, set_count: 18 }, + { zone_id: 'back', label: 'Dos', session_count: 3, occurrence_count: 5, set_count: 15 }, + { zone_id: 'arms', label: 'Bras', session_count: 2, occurrence_count: 4, set_count: 10 }, + { zone_id: 'core', label: 'Tronc', session_count: 1, occurrence_count: 2, set_count: 0 }, + ] }, + cardio: { available: false, reason: 'no_cardio_data_source' }, + }, + meta: { partial: true, invalid_data: false, generated_at: '2026-09-16T12:00:00Z' }, + } +} diff --git a/web/src/theme/app.css b/web/src/theme/app.css index ffaf7a3..3c9fc3a 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -87,7 +87,34 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; } .tile-header { min-width: 0; display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 1rem; } .tile-header h2 { margin-bottom: 0; } .tile-state { flex: none; color: var(--subtext-0); font: 600 .6rem var(--font-mono); letter-spacing: .1em; padding: .42rem .58rem; background: var(--surface-0); border-radius: 99rem; } -.tile-content { flex: 1; display: grid; place-items: center; } +.tile-state.is-available { color: var(--green); background: rgb(166 227 161 / 8%); } +.tile-state.is-partial { color: var(--yellow); background: rgb(249 226 175 / 8%); } +.tile-content { flex: 1; min-height: 0; margin-top: 1rem; display: block; overflow: auto; scrollbar-width: thin; } +.dashboard-data-message { margin: 0 0 1rem; padding: .65rem .8rem; color: var(--subtext-0); background: rgb(49 50 68 / 45%); border-radius: .5rem; font-size: .8rem; } +.dashboard-data-message.is-error { color: var(--peach); } +.dashboard-data-message.is-warning { color: var(--yellow); } +.tile-data-placeholder { height: 100%; display: grid; align-content: center; gap: .65rem; opacity: .5; } +.tile-data-placeholder span { height: .55rem; background: var(--surface-0); border-radius: 99rem; } +.tile-data-placeholder span:nth-child(2) { width: 72%; }.tile-data-placeholder span:nth-child(3) { width: 45%; } +.unavailable-content { height: 100%; display: grid; align-content: center; justify-items: center; text-align: center; color: var(--subtext-0); } +.unavailable-content > strong { color: var(--lavender); font: 500 1.7rem/1 var(--font-mono); } +.unavailable-content p { margin: .65rem 0 0; color: var(--text); font-weight: 600; } +.unavailable-content small { max-width: 24rem; margin-top: .55rem; color: var(--overlay-1); line-height: 1.45; } +.future-action { margin-top: 1.1rem; padding: .45rem .65rem; color: var(--surface-2); border: 1px dashed var(--surface-1); border-radius: .45rem; font: 600 .65rem var(--font-mono); } +.metric-row { display: flex; flex-wrap: wrap; gap: .8rem 1.5rem; margin-bottom: 1rem; } +.metric { display: grid; }.metric strong { color: var(--text); font-size: 1.45rem; line-height: 1; }.metric span { margin-top: .25rem; color: var(--subtext-0); font-size: .7rem; } +.activity-heatmap { display: grid; grid-template-rows: repeat(7, .72rem); grid-auto-flow: column; grid-auto-columns: minmax(.35rem, 1fr); gap: .22rem; min-width: 0; } +.activity-day { background: var(--surface-0); border-radius: .16rem; }.activity-day.is-active { background: var(--lavender); }.activity-day.is-multiple { background: var(--blue); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 25%); } +.chart-legend { margin: .75rem 0 0; color: var(--overlay-1); font-size: .67rem; line-height: 1.35; }.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: 1rem; font-weight: 650; }.secondary-label { margin: .25rem 0 0; color: var(--subtext-0); font-size: .72rem; } +.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: .72rem; }.progression-latest em { color: var(--green); font-size: .7rem; font-style: normal; } +.fact-list { margin: .8rem 0 0; }.fact-list.horizontal { display: flex; flex-wrap: wrap; gap: .75rem 1.3rem; }.fact-list div { min-width: 4rem; }.fact-list dt { color: var(--overlay-1); font: 600 .6rem var(--font-mono); text-transform: uppercase; }.fact-list dd { margin: .22rem 0 0; color: var(--text); font-size: .84rem; } +.zone-chips { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: .4rem; }.zone-chips span { padding: .35rem .55rem; color: var(--mauve); background: rgb(203 166 247 / 8%); border-radius: 99rem; font-size: .7rem; } +.record-list, .muscle-list { margin: 0; padding: 0; list-style: none; }.record-list { display: grid; gap: .7rem; }.record-list li { display: flex; justify-content: space-between; align-items: center; gap: .8rem; }.record-list div { min-width: 0; display: grid; }.record-list strong { overflow: hidden; color: var(--text); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }.record-list span { color: var(--overlay-1); font-size: .63rem; }.record-list b { flex: none; color: var(--peach); font-size: 1rem; } +.partial-note { margin: .8rem 0 0; color: var(--overlay-1); font-size: .65rem; } +.muscle-list { display: grid; gap: .55rem; }.muscle-label { display: flex; justify-content: space-between; gap: 1rem; color: var(--subtext-0); font-size: .7rem; }.muscle-label strong { color: var(--text); }.muscle-track { height: .3rem; margin-top: .25rem; 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: .18rem 0 0; color: var(--overlay-1); font-size: .6rem; } +.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; } .empty-state p { margin: .75rem 0 0; overflow-wrap: anywhere; } .empty-glyph { display: inline-grid; width: 2.4rem; height: 2.4rem; place-items: center; color: var(--lavender); background: rgb(180 190 254 / 9%); border-radius: 50%; font: 600 1rem var(--font-mono); }