diff --git a/docs/architecture.md b/docs/architecture.md
index 447c2a5..ec1ad46 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -309,6 +309,15 @@ If-Match guard optimistic revisions; mutation additionally requires exact
loopback or `trainlog.perf` Origin and an ephemeral startup-random CSRF header.
Invalid files are preserved for diagnosis while GET safely returns the default.
+`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` keeps the HTTP boundary singular: `App`
+loads and strictly validates one Dashboard snapshot, then supplies that same
+immutable business view to the Footer and the grid. Tile components perform
+presentation-only selection by logical size and direct sums of the 90 daily
+counts. They never reconstruct activity, progression, session, MAX or zone
+rules. The activity map encodes active/session facts directly; muscle bar
+length is exactly `session_count`, explicitly labelled. Responsive projection
+changes density, not the canonical layout or metric semantics.
+
`WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN` fixes one coherent, read-only
`GET /api/v1/dashboard` snapshot. One outer SQLite read savepoint covers every
projection; statements are finalized before return. The seven domains mean:
diff --git a/docs/current_state.md b/docs/current_state.md
index e68b3c0..13f1079 100644
--- a/docs/current_state.md
+++ b/docs/current_state.md
@@ -44,6 +44,7 @@ parallel implementation of its rules.
| Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` |
| Web Dashboard grid | `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` |
| Web Dashboard layout | `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` |
+| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
@@ -206,6 +207,12 @@ under the private XDG configuration directory, backend revalidation, atomic
protection, and durable React load/save/reset behavior. Only the desktop
12-column canon persists.
+`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` renders all seven domains from the one
+validated `/api/v1/dashboard` snapshot. Compact, medium and large densities
+only reveal existing facts. Activity uses a factual CSS day map, MAX lists are
+bounded 1/3/8, and muscle bars encode `session_count` only. Unavailable,
+partial, invalid and transport-error states never synthesize values.
+
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus
Android resource-parity, language-owner, typed sync-presentation, and
@@ -218,12 +225,9 @@ smoke test are not automated. The latter is why
## Active limitations
-- `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN`: the bounded Core snapshot,
- `/api/v1/dashboard`, explicit unavailable states and factual Footer data are
- implemented. The seven tile bodies intentionally retain shell placeholders.
-- `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN`: versioned disk/API persistence is
- complete. The seven tile bodies remain placeholders for
- `WEB_DASHBOARD_TILES_V1`.
+- `WEB_DASHBOARD_VISUALIZATIONS_V1`: advanced curves and muscle visualization
+ remain future work; Tile V1 intentionally uses bounded text, CSS and direct
+ factual encodings without ECharts.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test.
diff --git a/docs/design/web_dashboard_tiles_v1.md b/docs/design/web_dashboard_tiles_v1.md
new file mode 100644
index 0000000..3f00e21
--- /dev/null
+++ b/docs/design/web_dashboard_tiles_v1.md
@@ -0,0 +1,39 @@
+# Web Dashboard Tiles V1
+
+Status: `WEB_DASHBOARD_TILES_V1=PASS/FROZEN`
+
+## Boundary
+
+All seven tile components consume the single validated
+`TrainlogWebDashboardSnapshot` returned by `GET /api/v1/dashboard`. React may
+format values, select a bounded subset for the current logical size, and sum
+the already projected daily counters. It does not derive business truth.
+
+## Density contract
+
+`compact` exposes the essential fact, `medium` adds context, and `large`
+exposes all useful bounded fields. Resize never changes a metric. Responsive
+layouts reuse the same rule and never write a second layout truth.
+
+## Factual representations
+
+- Prochaine séance and Cardio render explicit unavailable states without
+ exposing backend reason identifiers or synthesizing values.
+- Activité maps each supplied day to inactive, active-one-session, or
+ active-multiple-sessions CSS classes. The colour is not physiological
+ intensity. Totals are direct sums of `session_count` and `set_count`.
+- Progression shows only the selected comparable identity, supplied points and
+ Core-owned `improved` flags. Legacy `0.0 kg` remains visible.
+- Dernière séance exposes supplied timestamps and counts. A null duration stays
+ unavailable; primary zones are the Core projection.
+- Records / MAX shows 1, 3 or at most 8 explicit records. A partial snapshot is
+ labelled as recent rather than exhaustive.
+- Répartition musculaire sorts the supplied primary zones deterministically
+ for display. Bar length represents only `session_count`; occurrences and
+ sets stay separate textual facts.
+
+The global shell reports loading/transport failure once. `invalid_data` is a
+discrete global warning; valid surviving projections remain usable. No
+ECharts, inferred score, demo datum or production fixture is part of V1.
+
+Next cursor: `WEB_DASHBOARD_VISUALIZATIONS_V1`.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index a0de87f..508d031 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -26,13 +26,13 @@ implemented. Session Generator V1 is hidden pending V2. AI session-draft
exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
-contract, frozen interactive grid and versioned layout persistence exist. Final
-tile rendering remains a future slice.
+contract, frozen interactive grid, versioned layout persistence and factual
+tile rendering exist. Advanced visualizations remain a future slice.
## Current cursor
```text
-CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_TILES_V1
+CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_VISUALIZATIONS_V1
```
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
@@ -46,6 +46,8 @@ projections without persisting layout state.
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` persists only that canonical desktop
layout with atomic XDG storage, optimistic revisions and protected HTTP
mutations; responsive projections remain derived.
+`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` replaces every shell placeholder with a
+size-adaptive component consuming only the frozen Dashboard snapshot.
## TRAINLOG_WEB_V1 implementation gate
@@ -79,8 +81,8 @@ must consume them without reconstructing or redefining their mathematics.
WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN]
- -> real-data tile delivery
- -> real-data visualization delivery
+ -> WEB_DASHBOARD_TILES_V1 [PASS/FROZEN]
+ -> WEB_DASHBOARD_VISUALIZATIONS_V1
```
The slice order may be refined by the next contract, but it must preserve the
diff --git a/docs/tests.md b/docs/tests.md
index 1e9c3cd..d9d77e9 100644
--- a/docs/tests.md
+++ b/docs/tests.md
@@ -85,6 +85,14 @@ clients, CSRF/Origin rejection and `trainlog.perf` Origin acceptance. Vitest
covers default/persisted/invalid sources, ETag/token parsing, canonical-only
PUT, conflict translation, loading, local cancel/reset and durable Save.
+`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
+field and Vitest fixtures confined to test sources. Coverage proves the 90-day
+activity map and direct totals, all compact/medium/large disclosure levels,
+legacy 0 kg visibility, `improved` use, unfinished-session duration absence,
+1/3/8 MAX bounds, factual muscle bars, unavailable cardio/next-session states,
+global loading/error/invalid signals, accessible textual equivalents and live
+density changes during keyboard resize.
+
## 2. Frozen Trainlog JSON v1
Run:
diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts
index c6db49c..4a507f2 100644
--- a/web/src/api/dashboard.ts
+++ b/web/src/api/dashboard.ts
@@ -1,51 +1,60 @@
-export interface DashboardFooter {
- user: string
- last_session_date: string | null
- last_zones: string[]
-}
+export interface DashboardFooter { user: string; last_session_date: string | null; last_zones: string[] }
+export interface ActivityDay { date: string; active: boolean; session_count: number; set_count: number }
+export interface WorkedZone { zone_id: string; label: string; session_count: number; occurrence_count: number; set_count: number }
+export interface ProgressionIdentity { exercise_id: string; exercise_name: string; equipment_id: string; equipment_label: string; tracking_mode: string; load_mode: string; dose: number }
+export interface ProgressionPoint { session_id: string; timestamp: string; metric_value: number; weight_kg: number; improved: boolean }
+export interface MaxRecord { session_id: string; entry_id: string; exercise_id: string; exercise_name: string; equipment_id: string; weight_kg: number; timestamp: string }
+type Unavailable = { available: false; reason: string }
+export type Progression = Unavailable | { available: true; identity: ProgressionIdentity; points: ProgressionPoint[] }
+export type LastSession = Unavailable | { available: true; session_id: string; started_at: string; ended_at: string | null; duration_seconds: number | null; exercise_count: number; set_count: number; continuous_count: number; max_count: number; primary_zones: WorkedZone[] }
export interface DashboardSnapshot {
api_version: 1
data: {
footer: DashboardFooter
- next_session: { available: false; reason: string }
- cardio: { available: false; reason: string }
- activity: { available: true; window_days: 90; days: unknown[] }
- progression: { available: boolean; reason?: string }
- last_session: { available: boolean; reason?: string }
- max_records: { available: boolean; records: unknown[] }
- muscle_distribution: { available: boolean; window_days: 30; primary_zones: unknown[] }
+ next_session: Unavailable
+ cardio: Unavailable
+ activity: { available: true; window_days: 90; days: ActivityDay[] }
+ progression: Progression
+ last_session: LastSession
+ max_records: { available: boolean; records: MaxRecord[] }
+ muscle_distribution: { available: boolean; window_days: 30; primary_zones: WorkedZone[] }
}
meta: { partial: boolean; invalid_data: boolean; generated_at: string }
}
-const record = (value: unknown): value is Record =>
- typeof value === 'object' && value !== null && !Array.isArray(value)
-const nonNegativeInteger = (value: unknown): value is number =>
- typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
-const workedZone = (value: unknown) => record(value) &&
- typeof value.zone_id === 'string' && typeof value.label === 'string' &&
- nonNegativeInteger(value.session_count) && nonNegativeInteger(value.occurrence_count) &&
- nonNegativeInteger(value.set_count)
+const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value)
+const finiteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
+const nonNegativeInteger = (value: unknown): value is number => finiteNumber(value) && Number.isSafeInteger(value) && value >= 0
+const nullableString = (value: unknown) => value === null || typeof value === 'string'
+const workedZone = (value: unknown) => record(value) && typeof value.zone_id === 'string' && typeof value.label === 'string' && nonNegativeInteger(value.session_count) && nonNegativeInteger(value.occurrence_count) && nonNegativeInteger(value.set_count)
+function validProgression(value: unknown): boolean {
+ if (!record(value) || typeof value.available !== 'boolean') return false
+ if (!value.available) return typeof value.reason === 'string'
+ if (!record(value.identity) || !Array.isArray(value.points)) return false
+ const identity = value.identity
+ return typeof identity.exercise_id === 'string' && typeof identity.exercise_name === 'string' && typeof identity.equipment_id === 'string' && typeof identity.equipment_label === 'string' && typeof identity.tracking_mode === 'string' && typeof identity.load_mode === 'string' && finiteNumber(identity.dose) && value.points.every((point) => record(point) && typeof point.session_id === 'string' && typeof point.timestamp === 'string' && finiteNumber(point.metric_value) && finiteNumber(point.weight_kg) && typeof point.improved === 'boolean')
+}
+
+function validLastSession(value: unknown): boolean {
+ if (!record(value) || typeof value.available !== 'boolean') return false
+ if (!value.available) return typeof value.reason === 'string'
+ return typeof value.session_id === 'string' && typeof value.started_at === 'string' && nullableString(value.ended_at) && (value.duration_seconds === null || nonNegativeInteger(value.duration_seconds)) && nonNegativeInteger(value.exercise_count) && nonNegativeInteger(value.set_count) && nonNegativeInteger(value.continuous_count) && nonNegativeInteger(value.max_count) && Array.isArray(value.primary_zones) && value.primary_zones.every(workedZone)
+}
+
+/** CONTRACT: a snapshot is rejected when a tile field has an unexpected type;
+ * components never infer missing business facts. */
export function parseDashboard(value: unknown): DashboardSnapshot {
if (!record(value) || value.api_version !== 1 || !record(value.data) || !record(value.meta)) throw new TypeError('dashboard invalide')
- const data = value.data
- const footer = data.footer
- const next = data.next_session
- const cardio = data.cardio
- const activity = data.activity
- const progression = data.progression
- const last = data.last_session
- const maxima = data.max_records
- const muscles = data.muscle_distribution
- if (!record(footer) || typeof footer.user !== 'string' || !(footer.last_session_date === null || typeof footer.last_session_date === 'string') || !Array.isArray(footer.last_zones) || !footer.last_zones.every((zone) => typeof zone === 'string')) throw new TypeError('footer invalide')
+ const data = value.data, footer = data.footer, next = data.next_session, cardio = data.cardio, activity = data.activity, maxima = data.max_records, muscles = data.muscle_distribution
+ if (!record(footer) || typeof footer.user !== 'string' || !nullableString(footer.last_session_date) || !Array.isArray(footer.last_zones) || !footer.last_zones.every((zone) => typeof zone === 'string')) throw new TypeError('footer invalide')
if (!record(next) || next.available !== false || typeof next.reason !== 'string') throw new TypeError('prochaine séance invalide')
if (!record(cardio) || cardio.available !== false || typeof cardio.reason !== 'string') throw new TypeError('cardio invalide')
if (!record(activity) || activity.available !== true || activity.window_days !== 90 || !Array.isArray(activity.days) || activity.days.length !== 90 || !activity.days.every((day) => record(day) && typeof day.date === 'string' && typeof day.active === 'boolean' && nonNegativeInteger(day.session_count) && nonNegativeInteger(day.set_count))) throw new TypeError('activité invalide')
- if (!record(progression) || typeof progression.available !== 'boolean' || (progression.available === false && typeof progression.reason !== 'string') || (progression.available === true && (!record(progression.identity) || !Array.isArray(progression.points)))) throw new TypeError('progression invalide')
- if (!record(last) || typeof last.available !== 'boolean' || (last.available === false && typeof last.reason !== 'string') || (last.available === true && (typeof last.session_id !== 'string' || typeof last.started_at !== 'string' || !Array.isArray(last.primary_zones) || !last.primary_zones.every(workedZone)))) throw new TypeError('dernière séance invalide')
- if (!record(maxima) || typeof maxima.available !== 'boolean' || !Array.isArray(maxima.records) || !maxima.records.every((item) => record(item) && typeof item.session_id === 'string' && typeof item.entry_id === 'string' && typeof item.exercise_id === 'string' && typeof item.exercise_name === 'string' && typeof item.equipment_id === 'string' && typeof item.weight_kg === 'number' && Number.isFinite(item.weight_kg) && typeof item.timestamp === 'string')) throw new TypeError('MAX invalide')
+ if (!validProgression(data.progression)) throw new TypeError('progression invalide')
+ if (!validLastSession(data.last_session)) throw new TypeError('dernière séance invalide')
+ if (!record(maxima) || typeof maxima.available !== 'boolean' || !Array.isArray(maxima.records) || !maxima.records.every((item) => record(item) && typeof item.session_id === 'string' && typeof item.entry_id === 'string' && typeof item.exercise_id === 'string' && typeof item.exercise_name === 'string' && typeof item.equipment_id === 'string' && finiteNumber(item.weight_kg) && typeof item.timestamp === 'string')) throw new TypeError('MAX invalide')
if (!record(muscles) || typeof muscles.available !== 'boolean' || muscles.window_days !== 30 || !Array.isArray(muscles.primary_zones) || !muscles.primary_zones.every(workedZone)) throw new TypeError('zones invalides')
if (typeof value.meta.partial !== 'boolean' || typeof value.meta.invalid_data !== 'boolean' || typeof value.meta.generated_at !== 'string') throw new TypeError('meta invalide')
return value as unknown as DashboardSnapshot
diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx
index 7a7cd5e..1f11163 100644
--- a/web/src/app/App.test.tsx
+++ b/web/src/app/App.test.tsx
@@ -63,7 +63,7 @@ describe('shell Trainlog', () => {
expect(footer.parentElement).toHaveClass('app-shell')
expect(screen.getByRole('link', { name: 'Trainlog — Dashboard' })).toHaveTextContent('TRAINLOG')
expect(screen.queryByText('Vos repères d’entraînement, sans extrapolation ni donnée inventée.')).not.toBeInTheDocument()
- await waitFor(() => expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7))
+ await waitFor(() => expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(6))
expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—')
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible')
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible')
diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx
index 6379d44..76225aa 100644
--- a/web/src/app/App.tsx
+++ b/web/src/app/App.tsx
@@ -21,6 +21,8 @@ export function App() {
const [healthPending, setHealthPending] = useState(true)
const [healthFailed, setHealthFailed] = useState(false)
const [dashboard, setDashboard] = useState(null)
+ const [dashboardPending, setDashboardPending] = useState(true)
+ const [dashboardFailed, setDashboardFailed] = useState(false)
useEffect(() => {
const controller = new AbortController()
@@ -28,11 +30,15 @@ export function App() {
setHealth(value)
setHealthFailed(false)
}).catch(() => setHealthFailed(true)).finally(() => setHealthPending(false))
- fetchDashboard(controller.signal).then(setDashboard).catch(() => setDashboard(null))
+ fetchDashboard(controller.signal).then((value) => {
+ setDashboard(value); setDashboardFailed(false)
+ }).catch(() => {
+ setDashboard(null); setDashboardFailed(true)
+ }).finally(() => setDashboardPending(false))
return () => controller.abort()
}, [])
- const content = route.id === 'dashboard' ? :
+ const content = route.id === 'dashboard' ? :
diff --git a/web/src/components/Tile.tsx b/web/src/components/Tile.tsx
index f46b74a..72d2d1a 100644
--- a/web/src/components/Tile.tsx
+++ b/web/src/components/Tile.tsx
@@ -9,9 +9,11 @@ interface TileProps {
size?: TileSize
editable?: boolean
onKeyDown?: KeyboardEventHandler
+ stateLabel?: string
+ stateTone?: 'available' | 'unavailable' | 'partial'
}
-export function Tile({ title, eyebrow, children, className = '', size = 'medium', editable = false, onKeyDown }: TileProps) {
+export function Tile({ title, eyebrow, children, className = '', size = 'medium', editable = false, onKeyDown, stateLabel = 'DISPONIBLE', stateTone = 'available' }: TileProps) {
return (
{eyebrow}
}
{title}
- INDISPONIBLE
+ {stateLabel}
{children}
diff --git a/web/src/dashboard/DashboardGrid.test.tsx b/web/src/dashboard/DashboardGrid.test.tsx
index ad1d518..cbbc1b7 100644
--- a/web/src/dashboard/DashboardGrid.test.tsx
+++ b/web/src/dashboard/DashboardGrid.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DashboardGrid } from './DashboardGrid'
import { DEFAULT_DASHBOARD_LAYOUT } from './dashboardLayout'
+import { dashboardFixture } from '../test/dashboardFixtures'
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
@@ -104,4 +105,27 @@ describe('grille interactive du Dashboard', () => {
expect(fetchMock).toHaveBeenCalledTimes(1)
window.innerWidth = 1440
})
+
+ it('rend les faits réels et adapte immédiatement la densité pendant le resize', async () => {
+ const snapshot = dashboardFixture()
+ render()
+ fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' }))
+ expect(screen.getByText('Identité comparable : reps · external')).toBeInTheDocument()
+ fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowLeft', shiftKey: true })
+ expect(screen.queryByText('Identité comparable : reps · external')).not.toBeInTheDocument()
+ expect(screen.getAllByText(/^Exercice \d$/)).toHaveLength(1)
+ fireEvent.keyDown(screen.getByRole('article', { name: 'Records / MAX, tuile modifiable' }), { key: 'ArrowDown', shiftKey: true })
+ expect(screen.getAllByText(/^Exercice \d$/)).toHaveLength(3)
+ })
+
+ it('signale discrètement loading, erreur globale et invalid_data', async () => {
+ const { rerender } = render()
+ await screen.findByRole('button', { name: 'Modifier l’agencement' })
+ expect(screen.getByRole('status')).toHaveTextContent('Chargement des données')
+ rerender()
+ expect(screen.getByRole('alert')).toHaveTextContent('momentanément indisponibles')
+ const snapshot = dashboardFixture(); snapshot.meta.invalid_data = true
+ rerender()
+ expect(screen.getByRole('status')).toHaveTextContent('données invalides ont été écartées')
+ })
})
diff --git a/web/src/dashboard/DashboardGrid.tsx b/web/src/dashboard/DashboardGrid.tsx
index b95f48e..025c9b5 100644
--- a/web/src/dashboard/DashboardGrid.tsx
+++ b/web/src/dashboard/DashboardGrid.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
-import { EmptyState } from '../components/EmptyState'
import { Tile } from '../components/Tile'
+import type { DashboardSnapshot } from '../api/dashboard'
import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout'
import {
cloneLayout,
@@ -17,15 +17,16 @@ import {
type TileId,
type TileLayout,
} from './dashboardLayout'
+import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles'
-const dashboardTiles: Readonly> = {
- 'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', message: 'Aucune séance préparée disponible.' },
- activity: { title: 'Activité', eyebrow: 'Régularité', message: 'Les données d’activité ne sont pas encore exposées.' },
- progression: { title: 'Progression', eyebrow: 'Évolution', message: 'La métrique de progression reste à définir.' },
- 'last-session': { title: 'Dernière séance', eyebrow: 'Historique', message: 'Aucune séance terminée disponible.' },
- 'max-records': { title: 'Records / MAX', eyebrow: 'Performances', message: 'Aucun MAX mesuré disponible.' },
- 'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', message: 'La répartition par zone n’est pas encore disponible.' },
- 'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', message: 'Aucune donnée cardio ou récupération disponible.' },
+const dashboardTiles: Readonly> = {
+ 'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile },
+ activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile },
+ progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile },
+ 'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile },
+ 'max-records': { title: 'Records / MAX', eyebrow: 'Performances', component: MaxRecordsTile },
+ 'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile },
+ 'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', component: CardioTile },
}
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
@@ -34,7 +35,9 @@ function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
return { columns: 12, rowHeight: 52 }
}
-export function DashboardGrid() {
+interface DashboardGridProps { dashboard?: DashboardSnapshot | null; pending?: boolean; failed?: boolean }
+
+export function DashboardGrid({ dashboard = null, pending = false, failed = false }: DashboardGridProps) {
const [layout, setLayout] = useState(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
const [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('')
@@ -147,6 +150,9 @@ export function DashboardGrid() {
)}
{preferenceError && {preferenceError}{preferenceError.includes('autre onglet') && }
}
+ {pending && Chargement des données du Dashboard…
}
+ {failed && Les données du Dashboard sont momentanément indisponibles.
}
+ {dashboard?.meta.invalid_data && Certaines données invalides ont été écartées.
}
{announcement}
{
const content = dashboardTiles[id]
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
+ const size = tileSize(dimensions)
+ const available = dashboard === null ? false : tileAvailable(dashboard, id)
+ const partial = dashboard?.meta.partial === true && id === 'max-records'
+ const TileContent = content.component
return (
onTileKeyDown(event, id)}
+ stateLabel={pending ? 'CHARGEMENT' : available ? (partial ? 'RÉCENT' : 'DISPONIBLE') : 'INDISPONIBLE'}
+ stateTone={available ? (partial ? 'partial' : 'available') : 'unavailable'}
>
-
+ {dashboard === null ?
: }
)
@@ -181,3 +193,13 @@ export function DashboardGrid() {
)
}
+
+function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean {
+ if (id === 'next-session') return snapshot.data.next_session.available
+ if (id === 'activity') return snapshot.data.activity.available
+ if (id === 'progression') return snapshot.data.progression.available
+ if (id === 'last-session') return snapshot.data.last_session.available
+ if (id === 'max-records') return snapshot.data.max_records.available
+ if (id === 'muscle-distribution') return snapshot.data.muscle_distribution.available
+ return snapshot.data.cardio.available
+}
diff --git a/web/src/dashboard/DashboardTiles.test.tsx b/web/src/dashboard/DashboardTiles.test.tsx
new file mode 100644
index 0000000..d861f8c
--- /dev/null
+++ b/web/src/dashboard/DashboardTiles.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen, within } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { dashboardFixture } from '../test/dashboardFixtures'
+import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile } from './DashboardTiles'
+
+describe('tuiles Dashboard alimentées par le contrat', () => {
+ it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => {
+ const snapshot = dashboardFixture()
+ const { rerender } = render()
+ expect(screen.getByText('Aucune séance préparée')).toBeInTheDocument()
+ expect(document.body).not.toHaveTextContent('no_persisted_executable_plan')
+ rerender()
+ expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument()
+ expect(document.body).not.toHaveTextContent('no_cardio_data_source')
+ expect(document.body).not.toHaveTextContent(/bpm|%/i)
+ })
+
+ it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => {
+ const { container } = render()
+ const heatmap = screen.getByRole('img')
+ expect(heatmap).toHaveAccessibleName(/2 jours actifs, 3 séances, 17 séries/)
+ expect(container.querySelectorAll('.activity-day')).toHaveLength(size === 'compact' ? 35 : 90)
+ })
+
+ it('présente l’identité comparable, le flag improved et le point legacy 0 kg', () => {
+ render()
+ expect(screen.getByText('Presse épaules')).toBeInTheDocument()
+ expect(screen.getByText('Amélioration enregistrée')).toBeInTheDocument()
+ expect(screen.getByText('0 kg')).toBeInTheDocument()
+ expect(screen.queryByText(/%/)).not.toBeInTheDocument()
+ })
+
+ it('omet toute durée fabriquée lorsque ended_at est absent et révèle les zones en grand', () => {
+ const snapshot = dashboardFixture()
+ const { rerender } = render()
+ expect(screen.getByText('Indisponible')).toBeInTheDocument()
+ if (!snapshot.data.last_session.available) throw new Error('fixture invalide')
+ snapshot.data.last_session.duration_seconds = 3720
+ rerender()
+ expect(screen.getByText('1 h 02')).toBeInTheDocument()
+ expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Pectoraux')
+ expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Épaules')
+ })
+
+ it.each([['compact', 1], ['medium', 3], ['large', 8]] as const)('borne les MAX en taille %s à %i', (size, expected) => {
+ const { container } = render()
+ expect(container.querySelectorAll('.record-list li')).toHaveLength(expected)
+ if (size === 'large') expect(screen.getByText('8 MAX récents affichés')).toBeInTheDocument()
+ })
+
+ it('encode les barres musculaires avec les séances et conserve les valeurs textuelles', () => {
+ const { container } = render()
+ const chest = within(container.querySelector('.muscle-list li') as HTMLElement)
+ expect(chest.getByText('4 séances')).toBeInTheDocument()
+ expect(chest.getByText(/6 occurrences · 18 séries/)).toBeInTheDocument()
+ expect((container.querySelector('.muscle-track span') as HTMLElement).style.width).toBe('100%')
+ expect(screen.getByText(/Longueur : nombre de séances/)).toBeInTheDocument()
+ })
+
+ it('gère les états indisponibles des domaines optionnels', () => {
+ const snapshot = dashboardFixture()
+ snapshot.data.progression = { available: false, reason: 'no_comparable_performance' }
+ snapshot.data.last_session = { available: false, reason: 'no_observable_session' }
+ snapshot.data.max_records = { available: false, records: [] }
+ snapshot.data.muscle_distribution = { available: false, window_days: 30, primary_zones: [] }
+ const { rerender } = render()
+ expect(screen.getByText(/Aucune performance/)).toBeInTheDocument()
+ rerender(); expect(screen.getByText(/Aucune séance observable/)).toBeInTheDocument()
+ rerender(); expect(screen.getByText(/Aucun MAX explicite/)).toBeInTheDocument()
+ rerender(); expect(screen.getByText(/Aucune zone travaillée/)).toBeInTheDocument()
+ })
+})
diff --git a/web/src/dashboard/DashboardTiles.tsx b/web/src/dashboard/DashboardTiles.tsx
new file mode 100644
index 0000000..134f54b
--- /dev/null
+++ b/web/src/dashboard/DashboardTiles.tsx
@@ -0,0 +1,82 @@
+import type { DashboardSnapshot, WorkedZone } from '../api/dashboard'
+import type { ComponentType } from 'react'
+import type { TileSize } from './dashboardLayout'
+import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat'
+
+interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize }
+
+export function NextSessionTile({ size }: TileDataProps) {
+ return —Aucune séance préparée
{size !== 'compact' &&
Aucune séance préparée n’est actuellement disponible.}{size === 'large' &&
Préparation à venir}
+}
+
+export function ActivityTile({ snapshot, size }: TileDataProps) {
+ const days = snapshot.data.activity.days
+ // CONTRACT: these are direct presentation totals of Core-projected counters,
+ // never an activity or physiological-intensity score.
+ const activeDays = days.filter((day) => day.active).length
+ const sessions = days.reduce((total, day) => total + day.session_count, 0)
+ const sets = days.reduce((total, day) => total + day.set_count, 0)
+ const shown = size === 'compact' ? days.slice(-35) : days
+ return
+
{size !== 'compact' && }{size === 'large' && }
+
+ {shown.map((day) => 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)}
+
+ {size === 'large' &&
Jour avec au moins une séance · la teinte indique uniquement le nombre de séances
}
+
+}
+
+export function ProgressionTile({ snapshot, size }: TileDataProps) {
+ const progression = snapshot.data.progression
+ if (!progression.available) return
+ const first = progression.points[0]
+ const last = progression.points.at(-1)
+ return
+
{progression.identity.exercise_name}
{size !== 'compact' &&
{progression.identity.equipment_label}
}
+ {last === undefined ?
Aucun point disponible
:
{formatWeight(last.weight_kg)}{formatDate(last.timestamp)}{last.improved && Amélioration enregistrée}
}
+ {size !== 'compact' &&
}
+ {size === 'large' &&
Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}
}
+
+}
+
+export function LastSessionTile({ snapshot, size }: TileDataProps) {
+ const session = snapshot.data.last_session
+ if (!session.available) return
+ return
+
{size === 'compact' ? formatDate(session.started_at) : formatDateTime(session.started_at)}
+
{size !== 'compact' && <>>}
+ {size === 'large' &&
{session.primary_zones.map((zone) => {zone.label})}
}
+
+}
+
+export function MaxRecordsTile({ snapshot, size }: TileDataProps) {
+ const records = snapshot.data.max_records.records
+ if (!snapshot.data.max_records.available || records.length === 0) return
+ const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8
+ return {records.slice(0, limit).map((record) => {record.exercise_name}{formatDate(record.timestamp)}{size === 'large' && record.equipment_id ? ` · ${record.equipment_id}` : ''}
{formatWeight(record.weight_kg)} )}
{snapshot.meta.partial && records.length >= 8 &&
8 MAX récents affichés
}
+}
+
+export function MuscleDistributionTile({ snapshot, size }: TileDataProps) {
+ const zones = snapshot.data.muscle_distribution.primary_zones
+ if (!snapshot.data.muscle_distribution.available || zones.length === 0) return
+ // INVARIANT: deterministic display order is separate from bar semantics;
+ // each bar length represents session_count alone.
+ const ordered = [...zones].sort((a, b) => b.session_count - a.session_count || b.occurrence_count - a.occurrence_count || a.label.localeCompare(b.label, 'fr'))
+ const shown = size === 'compact' ? ordered.slice(0, 3) : ordered
+ const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1)
+ return Longueur : nombre de séances sur 30 jours
+}
+
+function MuscleRow({ zone, maximum, detailed, full }: { zone: WorkedZone; maximum: number; detailed: boolean; full: boolean }) {
+ return {zone.label}{count(zone.session_count, 'séance')}
{detailed && {count(zone.occurrence_count, 'occurrence')}{full && ` · ${count(zone.set_count, 'série')}`}
}
+}
+
+export function CardioTile({ size }: TileDataProps) {
+ return —{size !== 'compact' &&
Aucune donnée cardio disponible
}{size === 'large' &&
Les mesures apparaîtront ici lorsqu’une source cardio sera enregistrée.}
+}
+
+function Metric({ value, label }: { value: string; label: string }) { return {value}{label}
}
+function Fact({ label, value }: { label: string; value: string }) { return {label}{value} }
+function Unavailable({ text }: { text: string }) { return }
+
+export type DashboardTileComponent = ComponentType
diff --git a/web/src/dashboard/dashboardFormat.ts b/web/src/dashboard/dashboardFormat.ts
new file mode 100644
index 0000000..502a988
--- /dev/null
+++ b/web/src/dashboard/dashboardFormat.ts
@@ -0,0 +1,22 @@
+const dateFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
+const dateTimeFormat = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
+const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 })
+
+function parsed(value: string): Date | null {
+ const result = new Date(value)
+ return Number.isNaN(result.valueOf()) ? null : result
+}
+
+export const formatDate = (value: string) => { const date = parsed(value); return date === null ? value : dateFormat.format(date) }
+export const formatDateTime = (value: string) => { const date = parsed(value); return date === null ? value : dateTimeFormat.format(date) }
+export const formatWeight = (value: number) => `${numberFormat.format(value)} kg`
+export const formatDose = (value: number) => numberFormat.format(value)
+export function formatDuration(seconds: number): string {
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const remaining = seconds % 60
+ if (hours > 0) return `${hours} h ${minutes.toString().padStart(2, '0')}`
+ if (minutes > 0) return `${minutes} min${remaining ? ` ${remaining} s` : ''}`
+ return `${remaining} s`
+}
+export const count = (value: number, singular: string, plural = `${singular}s`) => `${value} ${value === 1 ? singular : plural}`
diff --git a/web/src/routes/DashboardPage.tsx b/web/src/routes/DashboardPage.tsx
index 897c4b7..3d7d4ae 100644
--- a/web/src/routes/DashboardPage.tsx
+++ b/web/src/routes/DashboardPage.tsx
@@ -1,12 +1,15 @@
import { DashboardGrid } from '../dashboard/DashboardGrid'
+import type { DashboardSnapshot } from '../api/dashboard'
-export function DashboardPage() {
+interface DashboardPageProps { dashboard: DashboardSnapshot | null; pending: boolean; failed: boolean }
+
+export function DashboardPage({ dashboard, pending, failed }: DashboardPageProps) {
return (
)
}
diff --git a/web/src/test/dashboardFixtures.ts b/web/src/test/dashboardFixtures.ts
new file mode 100644
index 0000000..62755cc
--- /dev/null
+++ b/web/src/test/dashboardFixtures.ts
@@ -0,0 +1,40 @@
+import type { DashboardSnapshot } from '../api/dashboard'
+
+export function dashboardFixture(): DashboardSnapshot {
+ const start = Date.UTC(2026, 5, 19)
+ const days = Array.from({ length: 90 }, (_, index) => ({
+ date: new Date(start + index * 86_400_000).toISOString().slice(0, 10),
+ active: index === 87 || index === 89,
+ session_count: index === 87 ? 2 : index === 89 ? 1 : 0,
+ set_count: index === 87 ? 12 : index === 89 ? 5 : 0,
+ }))
+ return {
+ api_version: 1,
+ data: {
+ footer: { user: 'test', last_session_date: '2026-09-16', last_zones: ['Pectoraux', 'Épaules'] },
+ next_session: { available: false, reason: 'no_persisted_executable_plan' },
+ activity: { available: true, window_days: 90, days },
+ progression: {
+ available: true,
+ identity: { exercise_id: 'ex-1', exercise_name: 'Presse épaules', equipment_id: 'machine-1', equipment_label: 'Presse convergente', tracking_mode: 'reps', load_mode: 'external', dose: 10 },
+ points: [
+ { session_id: 's-1', timestamp: '2026-09-10T08:00:00+02:00', metric_value: 10, weight_kg: 0, improved: false },
+ { session_id: 's-2', timestamp: '2026-09-16T08:00:00+02:00', metric_value: 10, weight_kg: 18.5, improved: true },
+ ],
+ },
+ last_session: { available: true, session_id: 's-2', started_at: '2026-09-16T08:00:00+02:00', ended_at: null, duration_seconds: null, exercise_count: 6, set_count: 12, continuous_count: 2, max_count: 1, primary_zones: [
+ { zone_id: 'chest', label: 'Pectoraux', session_count: 1, occurrence_count: 2, set_count: 6 },
+ { zone_id: 'shoulders', label: 'Épaules', session_count: 1, occurrence_count: 2, set_count: 6 },
+ ] },
+ max_records: { available: true, records: Array.from({ length: 8 }, (_, index) => ({ session_id: 's-1', entry_id: `m-${index}`, exercise_id: `ex-${index}`, exercise_name: `Exercice ${index + 1}`, equipment_id: `machine-${index}`, weight_kg: 40 + index, timestamp: `2026-09-${String(16 - index).padStart(2, '0')}T08:00:00+02:00` })) },
+ muscle_distribution: { available: true, window_days: 30, primary_zones: [
+ { zone_id: 'chest', label: 'Pectoraux', session_count: 4, occurrence_count: 6, set_count: 18 },
+ { zone_id: 'back', label: 'Dos', session_count: 3, occurrence_count: 5, set_count: 15 },
+ { zone_id: 'arms', label: 'Bras', session_count: 2, occurrence_count: 4, set_count: 10 },
+ { zone_id: 'core', label: 'Tronc', session_count: 1, occurrence_count: 2, set_count: 0 },
+ ] },
+ cardio: { available: false, reason: 'no_cardio_data_source' },
+ },
+ meta: { partial: true, invalid_data: false, generated_at: '2026-09-16T12:00:00Z' },
+ }
+}
diff --git a/web/src/theme/app.css b/web/src/theme/app.css
index ffaf7a3..3c9fc3a 100644
--- a/web/src/theme/app.css
+++ b/web/src/theme/app.css
@@ -87,7 +87,34 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
.tile-header { min-width: 0; display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 1rem; }
.tile-header h2 { margin-bottom: 0; }
.tile-state { flex: none; color: var(--subtext-0); font: 600 .6rem var(--font-mono); letter-spacing: .1em; padding: .42rem .58rem; background: var(--surface-0); border-radius: 99rem; }
-.tile-content { flex: 1; display: grid; place-items: center; }
+.tile-state.is-available { color: var(--green); background: rgb(166 227 161 / 8%); }
+.tile-state.is-partial { color: var(--yellow); background: rgb(249 226 175 / 8%); }
+.tile-content { flex: 1; min-height: 0; margin-top: 1rem; display: block; overflow: auto; scrollbar-width: thin; }
+.dashboard-data-message { margin: 0 0 1rem; padding: .65rem .8rem; color: var(--subtext-0); background: rgb(49 50 68 / 45%); border-radius: .5rem; font-size: .8rem; }
+.dashboard-data-message.is-error { color: var(--peach); }
+.dashboard-data-message.is-warning { color: var(--yellow); }
+.tile-data-placeholder { height: 100%; display: grid; align-content: center; gap: .65rem; opacity: .5; }
+.tile-data-placeholder span { height: .55rem; background: var(--surface-0); border-radius: 99rem; }
+.tile-data-placeholder span:nth-child(2) { width: 72%; }.tile-data-placeholder span:nth-child(3) { width: 45%; }
+.unavailable-content { height: 100%; display: grid; align-content: center; justify-items: center; text-align: center; color: var(--subtext-0); }
+.unavailable-content > strong { color: var(--lavender); font: 500 1.7rem/1 var(--font-mono); }
+.unavailable-content p { margin: .65rem 0 0; color: var(--text); font-weight: 600; }
+.unavailable-content small { max-width: 24rem; margin-top: .55rem; color: var(--overlay-1); line-height: 1.45; }
+.future-action { margin-top: 1.1rem; padding: .45rem .65rem; color: var(--surface-2); border: 1px dashed var(--surface-1); border-radius: .45rem; font: 600 .65rem var(--font-mono); }
+.metric-row { display: flex; flex-wrap: wrap; gap: .8rem 1.5rem; margin-bottom: 1rem; }
+.metric { display: grid; }.metric strong { color: var(--text); font-size: 1.45rem; line-height: 1; }.metric span { margin-top: .25rem; color: var(--subtext-0); font-size: .7rem; }
+.activity-heatmap { display: grid; grid-template-rows: repeat(7, .72rem); grid-auto-flow: column; grid-auto-columns: minmax(.35rem, 1fr); gap: .22rem; min-width: 0; }
+.activity-day { background: var(--surface-0); border-radius: .16rem; }.activity-day.is-active { background: var(--lavender); }.activity-day.is-multiple { background: var(--blue); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 25%); }
+.chart-legend { margin: .75rem 0 0; color: var(--overlay-1); font-size: .67rem; line-height: 1.35; }.legend-swatch { display: inline-block; width: .55rem; height: .55rem; margin-right: .3rem; background: var(--lavender); border-radius: .12rem; }
+.primary-label { margin: 0; color: var(--text); font-size: 1rem; font-weight: 650; }.secondary-label { margin: .25rem 0 0; color: var(--subtext-0); font-size: .72rem; }
+.progression-latest { margin: .9rem 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem .7rem; }.progression-latest strong { color: var(--lavender); font-size: clamp(1.6rem, 3vw, 2.5rem); }.progression-latest span { color: var(--subtext-0); font-size: .72rem; }.progression-latest em { color: var(--green); font-size: .7rem; font-style: normal; }
+.fact-list { margin: .8rem 0 0; }.fact-list.horizontal { display: flex; flex-wrap: wrap; gap: .75rem 1.3rem; }.fact-list div { min-width: 4rem; }.fact-list dt { color: var(--overlay-1); font: 600 .6rem var(--font-mono); text-transform: uppercase; }.fact-list dd { margin: .22rem 0 0; color: var(--text); font-size: .84rem; }
+.zone-chips { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: .4rem; }.zone-chips span { padding: .35rem .55rem; color: var(--mauve); background: rgb(203 166 247 / 8%); border-radius: 99rem; font-size: .7rem; }
+.record-list, .muscle-list { margin: 0; padding: 0; list-style: none; }.record-list { display: grid; gap: .7rem; }.record-list li { display: flex; justify-content: space-between; align-items: center; gap: .8rem; }.record-list div { min-width: 0; display: grid; }.record-list strong { overflow: hidden; color: var(--text); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }.record-list span { color: var(--overlay-1); font-size: .63rem; }.record-list b { flex: none; color: var(--peach); font-size: 1rem; }
+.partial-note { margin: .8rem 0 0; color: var(--overlay-1); font-size: .65rem; }
+.muscle-list { display: grid; gap: .55rem; }.muscle-label { display: flex; justify-content: space-between; gap: 1rem; color: var(--subtext-0); font-size: .7rem; }.muscle-label strong { color: var(--text); }.muscle-track { height: .3rem; margin-top: .25rem; overflow: hidden; background: var(--surface-0); border-radius: 99rem; }.muscle-track span { display: block; height: 100%; background: var(--mauve); border-radius: inherit; }.muscle-list p { margin: .18rem 0 0; color: var(--overlay-1); font-size: .6rem; }
+.muted { color: var(--subtext-0); }
+.tile-compact .tile-content { margin-top: .65rem; }.tile-compact .tile-header { gap: .5rem; }.tile-compact .tile-state { padding: .3rem .42rem; }.tile-compact .activity-heatmap { grid-template-rows: repeat(5, .62rem); }.tile-compact .metric-row { margin-bottom: .65rem; }.tile-compact .progression-latest { margin-bottom: 0; }
.empty-state { max-width: 22rem; color: var(--subtext-0); text-align: center; line-height: 1.5; }
.empty-state p { margin: .75rem 0 0; overflow-wrap: anywhere; }
.empty-glyph { display: inline-grid; width: 2.4rem; height: 2.4rem; place-items: center; color: var(--lavender); background: rgb(180 190 254 / 9%); border-radius: 50%; font: 600 1rem var(--font-mono); }