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.
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:

View file

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

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.
`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

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
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:

View file

@ -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<string, unknown> =>
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<string, unknown> => 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

View file

@ -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')

View file

@ -21,6 +21,8 @@ export function App() {
const [healthPending, setHealthPending] = useState(true)
const [healthFailed, setHealthFailed] = useState(false)
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(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' ? <DashboardPage /> :
const content = route.id === 'dashboard' ? <DashboardPage dashboard={dashboard} pending={dashboardPending} failed={dashboardFailed} /> :
<PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} />

View file

@ -9,9 +9,11 @@ interface TileProps {
size?: TileSize
editable?: boolean
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 (
<article
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>}
<h2>{title}</h2>
</div>
<span className="tile-state">INDISPONIBLE</span>
<span className={`tile-state is-${stateTone}`}>{stateLabel}</span>
</div>
<div className="tile-content">{children}</div>
</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 { 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(<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 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<Record<TileId, { title: string, eyebrow: string, message: string }>> = {
'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<Record<TileId, { title: string, eyebrow: string, component: DashboardTileComponent }>> = {
'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<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
const [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('')
@ -147,6 +150,9 @@ export function DashboardGrid() {
)}
</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>
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
<GridLayout
@ -162,16 +168,22 @@ export function DashboardGrid() {
{TILE_IDS.map((id) => {
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 (
<div key={id} data-testid={`grid-item-${id}`}>
<Tile
title={content.title}
eyebrow={content.eyebrow}
size={tileSize(dimensions)}
size={size}
editable={editing && desktop}
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>
</div>
)
@ -181,3 +193,13 @@ export function DashboardGrid() {
</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 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 (
<section className="page" aria-labelledby="page-title">
<div className="page-heading">
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
</div>
<DashboardGrid />
<DashboardGrid dashboard={dashboard} pending={pending} failed={failed} />
</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 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); }