feat(web): deliver factual dashboard tiles

This commit is contained in:
fy59 2026-09-16 14:35:37 +02:00
parent 799c2e0a52
commit ecec81f077
17 changed files with 435 additions and 64 deletions

View file

@ -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. loopback or `trainlog.perf` Origin and an ephemeral startup-random CSRF header.
Invalid files are preserved for diagnosis while GET safely returns the default. 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 `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

@ -44,6 +44,7 @@ parallel implementation of its rules.
| Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` | | Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` |
| 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` |
| 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
@ -206,6 +207,12 @@ under the private XDG configuration directory, backend revalidation, atomic
protection, and durable React load/save/reset behavior. Only the desktop protection, and durable React load/save/reset behavior. Only the desktop
12-column canon persists. 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, `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
@ -218,12 +225,9 @@ smoke test are not automated. The latter is why
## Active limitations ## Active limitations
- `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN`: the bounded Core snapshot, - `WEB_DASHBOARD_VISUALIZATIONS_V1`: advanced curves and muscle visualization
`/api/v1/dashboard`, explicit unavailable states and factual Footer data are remain future work; Tile V1 intentionally uses bounded text, CSS and direct
implemented. The seven tile bodies intentionally retain shell placeholders. factual encodings without ECharts.
- `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN`: versioned disk/API persistence is
complete. The seven tile bodies remain placeholders for
`WEB_DASHBOARD_TILES_V1`.
- `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,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`.

View file

@ -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. exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract, frozen interactive grid and versioned layout persistence exist. Final contract, frozen interactive grid, versioned layout persistence and factual
tile rendering remains a future slice. tile rendering exist. Advanced visualizations remain a future slice.
## Current cursor ## Current cursor
```text ```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, `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 `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` persists only that canonical desktop
layout with atomic XDG storage, optimistic revisions and protected HTTP 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
size-adaptive component consuming only the frozen Dashboard snapshot.
## TRAINLOG_WEB_V1 implementation gate ## 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_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]
-> real-data tile delivery -> WEB_DASHBOARD_TILES_V1 [PASS/FROZEN]
-> real-data visualization delivery -> WEB_DASHBOARD_VISUALIZATIONS_V1
``` ```
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

@ -85,6 +85,14 @@ clients, CSRF/Origin rejection and `trainlog.perf` Origin acceptance. Vitest
covers default/persisted/invalid sources, ETag/token parsing, canonical-only covers default/persisted/invalid sources, ETag/token parsing, canonical-only
PUT, conflict translation, loading, local cancel/reset and durable Save. 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 ## 2. Frozen Trainlog JSON v1
Run: Run:

View file

@ -1,51 +1,60 @@
export interface DashboardFooter { export interface DashboardFooter { user: string; last_session_date: string | null; last_zones: string[] }
user: string export interface ActivityDay { date: string; active: boolean; session_count: number; set_count: number }
last_session_date: string | null export interface WorkedZone { zone_id: string; label: string; session_count: number; occurrence_count: number; set_count: number }
last_zones: string[] 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 { export interface DashboardSnapshot {
api_version: 1 api_version: 1
data: { data: {
footer: DashboardFooter footer: DashboardFooter
next_session: { available: false; reason: string } next_session: Unavailable
cardio: { available: false; reason: string } cardio: Unavailable
activity: { available: true; window_days: 90; days: unknown[] } activity: { available: true; window_days: 90; days: ActivityDay[] }
progression: { available: boolean; reason?: string } progression: Progression
last_session: { available: boolean; reason?: string } last_session: LastSession
max_records: { available: boolean; records: unknown[] } max_records: { available: boolean; records: MaxRecord[] }
muscle_distribution: { available: boolean; window_days: 30; primary_zones: unknown[] } muscle_distribution: { available: boolean; window_days: 30; primary_zones: WorkedZone[] }
} }
meta: { partial: boolean; invalid_data: boolean; generated_at: string } meta: { partial: boolean; invalid_data: boolean; generated_at: string }
} }
const record = (value: unknown): value is Record<string, unknown> => const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
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 => const nonNegativeInteger = (value: unknown): value is number => finiteNumber(value) && Number.isSafeInteger(value) && value >= 0
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 const nullableString = (value: unknown) => value === null || typeof value === 'string'
const workedZone = (value: unknown) => record(value) && 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)
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 { export function parseDashboard(value: unknown): DashboardSnapshot {
if (!record(value) || value.api_version !== 1 || !record(value.data) || !record(value.meta)) throw new TypeError('dashboard invalide') if (!record(value) || value.api_version !== 1 || !record(value.data) || !record(value.meta)) throw new TypeError('dashboard invalide')
const data = value.data 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
const footer = data.footer 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')
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')
if (!record(next) || next.available !== false || typeof next.reason !== 'string') throw new TypeError('prochaine séance 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(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(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 (!validProgression(data.progression)) 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 (!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' && typeof item.weight_kg === 'number' && Number.isFinite(item.weight_kg) && typeof item.timestamp === 'string')) throw new TypeError('MAX 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 (!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') 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 return value as unknown as DashboardSnapshot

View file

@ -63,7 +63,7 @@ describe('shell Trainlog', () => {
expect(footer.parentElement).toHaveClass('app-shell') expect(footer.parentElement).toHaveClass('app-shell')
expect(screen.getByRole('link', { name: 'Trainlog — Dashboard' })).toHaveTextContent('TRAINLOG') 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() 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('Utilisateur')).toHaveTextContent('—')
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible') expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible')
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible') expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible')

View file

@ -21,6 +21,8 @@ export function App() {
const [healthPending, setHealthPending] = useState(true) const [healthPending, setHealthPending] = useState(true)
const [healthFailed, setHealthFailed] = useState(false) const [healthFailed, setHealthFailed] = useState(false)
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null) const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
const [dashboardPending, setDashboardPending] = useState(true)
const [dashboardFailed, setDashboardFailed] = useState(false)
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@ -28,11 +30,15 @@ export function App() {
setHealth(value) setHealth(value)
setHealthFailed(false) setHealthFailed(false)
}).catch(() => setHealthFailed(true)).finally(() => setHealthPending(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() return () => controller.abort()
}, []) }, [])
const content = route.id === 'dashboard' ? <DashboardPage /> : const content = route.id === 'dashboard' ? <DashboardPage dashboard={dashboard} pending={dashboardPending} failed={dashboardFailed} /> :
<PlaceholderPage title={placeholderContent[route.id][0]} <PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]} eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} /> description={placeholderContent[route.id][2]} />

View file

@ -9,9 +9,11 @@ interface TileProps {
size?: TileSize size?: TileSize
editable?: boolean editable?: boolean
onKeyDown?: KeyboardEventHandler<HTMLElement> onKeyDown?: KeyboardEventHandler<HTMLElement>
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 ( return (
<article <article
className={`tile tile-${size} ${className}`.trim()} className={`tile tile-${size} ${className}`.trim()}
@ -25,7 +27,7 @@ export function Tile({ title, eyebrow, children, className = '', size = 'medium'
{eyebrow && <p className="eyebrow">{eyebrow}</p>} {eyebrow && <p className="eyebrow">{eyebrow}</p>}
<h2>{title}</h2> <h2>{title}</h2>
</div> </div>
<span className="tile-state">INDISPONIBLE</span> <span className={`tile-state is-${stateTone}`}>{stateLabel}</span>
</div> </div>
<div className="tile-content">{children}</div> <div className="tile-content">{children}</div>
</article> </article>

View file

@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DashboardGrid } from './DashboardGrid' import { DashboardGrid } from './DashboardGrid'
import { DEFAULT_DASHBOARD_LAYOUT } from './dashboardLayout' import { DEFAULT_DASHBOARD_LAYOUT } from './dashboardLayout'
import { dashboardFixture } from '../test/dashboardFixtures'
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('fetch', vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { vi.stubGlobal('fetch', vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
@ -104,4 +105,27 @@ describe('grille interactive du Dashboard', () => {
expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledTimes(1)
window.innerWidth = 1440 window.innerWidth = 1440
}) })
it('rend les faits réels et adapte immédiatement la densité pendant le resize', async () => {
const snapshot = dashboardFixture()
render(<DashboardGrid dashboard={snapshot} />)
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(<DashboardGrid pending />)
await screen.findByRole('button', { name: 'Modifier l’agencement' })
expect(screen.getByRole('status')).toHaveTextContent('Chargement des données')
rerender(<DashboardGrid failed />)
expect(screen.getByRole('alert')).toHaveTextContent('momentanément indisponibles')
const snapshot = dashboardFixture(); snapshot.meta.invalid_data = true
rerender(<DashboardGrid dashboard={snapshot} />)
expect(screen.getByRole('status')).toHaveTextContent('données invalides ont été écartées')
})
}) })

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout' import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
import { EmptyState } from '../components/EmptyState'
import { Tile } from '../components/Tile' import { Tile } from '../components/Tile'
import type { DashboardSnapshot } from '../api/dashboard'
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout' import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
import { import {
cloneLayout, cloneLayout,
@ -17,15 +17,16 @@ import {
type TileId, type TileId,
type TileLayout, type TileLayout,
} from './dashboardLayout' } from './dashboardLayout'
import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles'
const dashboardTiles: Readonly<Record<TileId, { title: string, eyebrow: string, message: string }>> = { const dashboardTiles: Readonly<Record<TileId, { title: string, eyebrow: string, component: DashboardTileComponent }>> = {
'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', message: 'Aucune séance préparée disponible.' }, 'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile },
activity: { title: 'Activité', eyebrow: 'Régularité', message: 'Les données d’activité ne sont pas encore exposées.' }, activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile },
progression: { title: 'Progression', eyebrow: 'Évolution', message: 'La métrique de progression reste à définir.' }, progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile },
'last-session': { title: 'Dernière séance', eyebrow: 'Historique', message: 'Aucune séance terminée disponible.' }, 'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile },
'max-records': { title: 'Records / MAX', eyebrow: 'Performances', message: 'Aucun MAX mesuré disponible.' }, 'max-records': { title: 'Records / MAX', eyebrow: 'Performances', component: MaxRecordsTile },
'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', message: 'La répartition par zone n’est pas encore disponible.' }, 'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile },
'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', message: 'Aucune donnée cardio ou récupération disponible.' }, 'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', component: CardioTile },
} }
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } { 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 } 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<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('') const [announcement, setAnnouncement] = useState('')
@ -147,6 +150,9 @@ export function DashboardGrid() {
)} )}
</div> </div>
{preferenceError && <div className="layout-message is-error" role="alert">{preferenceError}{preferenceError.includes('autre onglet') && <button className="layout-action" type="button" onClick={() => void reloadLayout()}>Recharger l’agencement</button>}</div>} {preferenceError && <div className="layout-message is-error" role="alert">{preferenceError}{preferenceError.includes('autre onglet') && <button className="layout-action" type="button" onClick={() => void reloadLayout()}>Recharger l’agencement</button>}</div>}
{pending && <div className="dashboard-data-message" role="status">Chargement des données du Dashboard…</div>}
{failed && <div className="dashboard-data-message is-error" role="alert">Les données du Dashboard sont momentanément indisponibles.</div>}
{dashboard?.meta.invalid_data && <div className="dashboard-data-message is-warning" role="status">Certaines données invalides ont été écartées.</div>}
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p> <p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}> <div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
<GridLayout <GridLayout
@ -162,16 +168,22 @@ export function DashboardGrid() {
{TILE_IDS.map((id) => { {TILE_IDS.map((id) => {
const content = dashboardTiles[id] const content = dashboardTiles[id]
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0] 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 ( return (
<div key={id} data-testid={`grid-item-${id}`}> <div key={id} data-testid={`grid-item-${id}`}>
<Tile <Tile
title={content.title} title={content.title}
eyebrow={content.eyebrow} eyebrow={content.eyebrow}
size={tileSize(dimensions)} size={size}
editable={editing && desktop} editable={editing && desktop}
onKeyDown={(event) => onTileKeyDown(event, id)} onKeyDown={(event) => onTileKeyDown(event, id)}
stateLabel={pending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
> >
<EmptyState message={content.message} /> {dashboard === null ? <div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div> : <TileContent snapshot={dashboard} size={size} />}
</Tile> </Tile>
</div> </div>
) )
@ -181,3 +193,13 @@ export function DashboardGrid() {
</div> </div>
) )
} }
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
}

View file

@ -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(<NextSessionTile snapshot={snapshot} size="large" />)
expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
rerender(<CardioTile snapshot={snapshot} size="large" />)
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(<ActivityTile snapshot={dashboardFixture()} size={size} />)
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(<ProgressionTile snapshot={dashboardFixture()} size="large" />)
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(<LastSessionTile snapshot={snapshot} size="medium" />)
expect(screen.getByText('Indisponible')).toBeInTheDocument()
if (!snapshot.data.last_session.available) throw new Error('fixture invalide')
snapshot.data.last_session.duration_seconds = 3720
rerender(<LastSessionTile snapshot={snapshot} size="large" />)
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(<MaxRecordsTile snapshot={dashboardFixture()} size={size} />)
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(<MuscleDistributionTile snapshot={dashboardFixture()} size="large" />)
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(<ProgressionTile snapshot={snapshot} size="compact" />)
expect(screen.getByText(/Aucune performance/)).toBeInTheDocument()
rerender(<LastSessionTile snapshot={snapshot} size="compact" />); expect(screen.getByText(/Aucune séance observable/)).toBeInTheDocument()
rerender(<MaxRecordsTile snapshot={snapshot} size="compact" />); expect(screen.getByText(/Aucun MAX explicite/)).toBeInTheDocument()
rerender(<MuscleDistributionTile snapshot={snapshot} size="compact" />); expect(screen.getByText(/Aucune zone travaillée/)).toBeInTheDocument()
})
})

View file

@ -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 <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>Aucune séance préparée</p>{size !== 'compact' && <small>Aucune séance préparée n’est actuellement disponible.</small>}{size === 'large' && <span className="future-action" aria-hidden="true">Préparation à venir</span>}</div>
}
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 <div className="activity-content">
<div className="metric-row"><Metric value={String(activeDays)} label="jours actifs" />{size !== 'compact' && <Metric value={String(sessions)} label="séances" />}{size === 'large' && <Metric value={String(sets)} label="séries" />}</div>
<div className="activity-heatmap" role="img" aria-label={`Sur 90 jours : ${count(activeDays, 'jour actif', 'jours actifs')}, ${count(sessions, 'séance')}, ${count(sets, 'série')}.`}>
{shown.map((day) => <span key={day.date} className={`activity-day${day.active ? ' is-active' : ''}${day.session_count > 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)}
</div>
{size === 'large' && <p className="chart-legend"><span className="legend-swatch" /> Jour avec au moins une séance · la teinte indique uniquement le nombre de séances</p>}
</div>
}
export function ProgressionTile({ snapshot, size }: TileDataProps) {
const progression = snapshot.data.progression
if (!progression.available) return <Unavailable text="Aucune performance comparable disponible" />
const first = progression.points[0]
const last = progression.points.at(-1)
return <div className="progression-content">
<div><p className="primary-label">{progression.identity.exercise_name}</p>{size !== 'compact' && <p className="secondary-label">{progression.identity.equipment_label}</p>}</div>
{last === undefined ? <p className="muted">Aucun point disponible</p> : <div className="progression-latest"><strong>{formatWeight(last.weight_kg)}</strong><span>{formatDate(last.timestamp)}</span>{last.improved && <em>Amélioration enregistrée</em>}</div>}
{size !== 'compact' && <dl className="fact-list horizontal"><Fact label="Dose" value={formatDose(progression.identity.dose)} /><Fact label="Premier" value={first ? formatWeight(first.weight_kg) : '—'} /><Fact label="Dernier" value={last ? formatWeight(last.weight_kg) : '—'} /><Fact label="Points" value={String(progression.points.length)} /></dl>}
{size === 'large' && <p className="chart-legend">Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}</p>}
</div>
}
export function LastSessionTile({ snapshot, size }: TileDataProps) {
const session = snapshot.data.last_session
if (!session.available) return <Unavailable text="Aucune séance observable disponible" />
return <div className="session-content">
<p className="primary-label">{size === 'compact' ? formatDate(session.started_at) : formatDateTime(session.started_at)}</p>
<dl className="fact-list horizontal"><Fact label="Exercices" value={String(session.exercise_count)} /><Fact label="Séries" value={String(session.set_count)} />{size !== 'compact' && <><Fact label="Durée" value={session.duration_seconds === null ? 'Indisponible' : formatDuration(session.duration_seconds)} /><Fact label="Activités continues" value={String(session.continuous_count)} /><Fact label="MAX explicites" value={String(session.max_count)} /></>}</dl>
{size === 'large' && <div className="zone-chips" aria-label="Zones primaires travaillées">{session.primary_zones.map((zone) => <span key={zone.zone_id}>{zone.label}</span>)}</div>}
</div>
}
export function MaxRecordsTile({ snapshot, size }: TileDataProps) {
const records = snapshot.data.max_records.records
if (!snapshot.data.max_records.available || records.length === 0) return <Unavailable text="Aucun MAX explicite disponible" />
const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8
return <div className="max-content"><ol className="record-list">{records.slice(0, limit).map((record) => <li key={record.entry_id}><div><strong>{record.exercise_name}</strong><span>{formatDate(record.timestamp)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}</span></div><b>{formatWeight(record.weight_kg)}</b></li>)}</ol>{snapshot.meta.partial && records.length >= 8 && <p className="partial-note">8 MAX récents affichés</p>}</div>
}
export function MuscleDistributionTile({ snapshot, size }: TileDataProps) {
const zones = snapshot.data.muscle_distribution.primary_zones
if (!snapshot.data.muscle_distribution.available || zones.length === 0) return <Unavailable text="Aucune zone travaillée sur 30 jours" />
// 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 <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>
}
function MuscleRow({ zone, maximum, detailed, full }: { zone: WorkedZone; maximum: number; detailed: boolean; full: boolean }) {
return <li><div className="muscle-label"><strong>{zone.label}</strong><span>{count(zone.session_count, 'séance')}</span></div><div className="muscle-track" aria-hidden="true"><span style={{ width: `${(zone.session_count / maximum) * 100}%` }} /></div>{detailed && <p>{count(zone.occurrence_count, 'occurrence')}{full && ` · ${count(zone.set_count, 'série')}`}</p>}</li>
}
export function CardioTile({ size }: TileDataProps) {
return <div className="unavailable-content"><strong aria-hidden="true">—</strong>{size !== 'compact' && <p>Aucune donnée cardio disponible</p>}{size === 'large' && <small>Les mesures apparaîtront ici lorsqu’une source cardio sera enregistrée.</small>}</div>
}
function Metric({ value, label }: { value: string; label: string }) { return <div className="metric"><strong>{value}</strong><span>{label}</span></div> }
function Fact({ label, value }: { label: string; value: string }) { return <div><dt>{label}</dt><dd>{value}</dd></div> }
function Unavailable({ text }: { text: string }) { return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>{text}</p></div> }
export type DashboardTileComponent = ComponentType<TileDataProps>

View file

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

View file

@ -1,12 +1,15 @@
import { DashboardGrid } from '../dashboard/DashboardGrid' 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 ( return (
<section className="page" aria-labelledby="page-title"> <section className="page" aria-labelledby="page-title">
<div className="page-heading"> <div className="page-heading">
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div> <div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
</div> </div>
<DashboardGrid /> <DashboardGrid dashboard={dashboard} pending={pending} failed={failed} />
</section> </section>
) )
} }

View file

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

View file

@ -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 { min-width: 0; display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 1rem; }
.tile-header h2 { margin-bottom: 0; } .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-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 { 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-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); } .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); }