refactor(web): refine analysis visual presentation

This commit is contained in:
fy59 2026-09-20 21:06:10 +02:00
parent c31c394e4f
commit 6948e0bbd5
9 changed files with 4367 additions and 773 deletions

View file

@ -13,6 +13,14 @@ Development opened after stable v0.1.3 release.
links, safe query fallbacks, null/empty states, French/English presentation, links, safe query fallbacks, null/empty states, French/English presentation,
keyboard focus, and responsive charts are covered without estimating 1RM, keyboard focus, and responsive charts are covered without estimating 1RM,
readiness, fatigue, recovery, body fat, or performance. readiness, fatigue, recovery, body fat, or performance.
- Refined the Analyse V1 and Dashboard presentation without changing their
read models or API contracts. Period controls now share the section toolbar;
durations use the shared human-readable formatter; incompatible exercise
metrics remain hidden; activity, exercise, and measurement charts expose
chronological dates, units, and factual tooltips; BODY ZONE silhouettes and
exposure comparisons are more legible; and active-Program summaries use a
compact structured progression layout. Persisted Dashboard tile identities
and layout behavior remain unchanged.
## 0.1.3 — 2026-09-20 ## 0.1.3 — 2026-09-20

View file

@ -141,7 +141,7 @@ parallel implementation of its rules.
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) | | Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` | | Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Current operational cursor | `TRAINLOG_WEB_ANALYSIS_V1` (implemented, visual review pending) | | Current operational cursor | `TRAINLOG_WEB_ANALYSIS_V1` (implemented and visually refined, user review pending) |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -1,138 +1,376 @@
import { render, screen, within } from '@testing-library/react' import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from 'vitest' import { describe, expect, it } from "vitest";
import { dashboardFixture } from '../test/dashboardFixtures' import { dashboardFixture } from "../test/dashboardFixtures";
import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile } from './DashboardTiles' import {
ActivityTile,
CardioTile,
LastSessionTile,
MaxRecordsTile,
MuscleDistributionTile,
NextSessionTile,
ProgressionTile,
} from "./DashboardTiles";
import { analysisFixture } from "../api/analysis.test";
describe('tuiles Dashboard alimentées par le contrat', () => { describe("tuiles Dashboard alimentées par le contrat", () => {
it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => { it("présente les indisponibilités sans exposer les raisons techniques ni inventer de faits", () => {
const snapshot = dashboardFixture() const snapshot = dashboardFixture();
const { rerender } = render(<NextSessionTile preparedItems={{api_version:1,generated_at:'2026-09-17T12:00:00Z',partial:false,items:[]}} pending={false} failed={false} size="large" />) const { rerender } = render(
expect(screen.getByText('Aucun élément disponible')).toBeInTheDocument() <NextSessionTile
expect(document.body).not.toHaveTextContent('no_persisted_executable_plan') preparedItems={{
rerender(<CardioTile snapshot={snapshot} size="large" />) api_version: 1,
expect(screen.getByText('Aucune donnée cardio disponible')).toBeInTheDocument() generated_at: "2026-09-17T12:00:00Z",
expect(document.body).not.toHaveTextContent('no_cardio_data_source') partial: false,
expect(document.body).not.toHaveTextContent(/bpm|%/i) items: [],
}) }}
pending={false}
failed={false}
size="large"
/>,
);
expect(screen.getByText("Aucun élément disponible")).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('sépare proposition à valider et brouillon actif sans fabriquer de réalisé', () => { it("sépare proposition à valider et brouillon actif sans fabriquer de réalisé", () => {
const proposal = { const proposal = {
api_version: 1 as const, api_version: 1 as const,
generated_at: '2026-09-17T12:00:00Z', generated_at: "2026-09-17T12:00:00Z",
partial: false, partial: false,
items: [{ identity: 'aid_one', kind: 'ai_proposal' as const, title: 'Préparation', planned_for: '2026-09-18', state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' }], items: [
} {
identity: "aid_one",
kind: "ai_proposal" as const,
title: "Préparation",
planned_for: "2026-09-18",
state: "published",
sort_timestamp: "2026-09-17T12:00:00Z",
occurrence_count: 6,
provenance: "ai_import",
},
],
};
const { rerender } = render( const { rerender } = render(
<NextSessionTile size="large" preparedItems={proposal} pending={false} failed={false} />, <NextSessionTile
) size="large"
expect(screen.getByText('Proposition préparée · à valider')).toBeInTheDocument() preparedItems={proposal}
expect(screen.getByText('Préparation')).toBeInTheDocument() pending={false}
expect(document.body).not.toHaveTextContent(/réalisée|terminée/i) failed={false}
/>,
);
expect(
screen.getByText("Proposition préparée · à valider"),
).toBeInTheDocument();
expect(screen.getByText("Préparation")).toBeInTheDocument();
expect(document.body).not.toHaveTextContent(/réalisée|terminée/i);
rerender(<NextSessionTile size="large" preparedItems={{ ...proposal, items: [{ ...proposal.items[0], identity: 'se_one', kind: 'execution_draft', title: 'training', planned_for: null, state: 'active', occurrence_count: 2, provenance: 'execution_store' }] }} pending={false} failed={false} />) rerender(
expect(screen.getByText('Brouillon d’exécution · à reprendre')).toBeInTheDocument() <NextSessionTile
}) size="large"
preparedItems={{
...proposal,
items: [
{
...proposal.items[0],
identity: "se_one",
kind: "execution_draft",
title: "training",
planned_for: null,
state: "active",
occurrence_count: 2,
provenance: "execution_store",
},
],
}}
pending={false}
failed={false}
/>,
);
expect(
screen.getByText("Brouillon d’exécution · à reprendre"),
).toBeInTheDocument();
});
it('conserve une lecture durable lorsque son actualisation échoue', () => { it("conserve une lecture durable lorsque son actualisation échoue", () => {
render(<NextSessionTile size="medium" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [{ identity: 'aid_one', kind: 'ai_proposal', title: 'Conservée', planned_for: null, state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 1, provenance: 'ai_import' }] }} pending={false} failed />) render(
expect(screen.getByText('Conservée')).toBeInTheDocument() <NextSessionTile
expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument() size="medium"
expect(screen.queryByText('Aucun élément disponible')).not.toBeInTheDocument() preparedItems={{
}) api_version: 1,
generated_at: "2026-09-17T12:00:00Z",
partial: false,
items: [
{
identity: "aid_one",
kind: "ai_proposal",
title: "Conservée",
planned_for: null,
state: "published",
sort_timestamp: "2026-09-17T12:00:00Z",
occurrence_count: 1,
provenance: "ai_import",
},
],
}}
pending={false}
failed
/>,
);
expect(screen.getByText("Conservée")).toBeInTheDocument();
expect(screen.getByText(/Dernière lecture conservée/)).toBeInTheDocument();
expect(
screen.queryByText("Aucun élément disponible"),
).not.toBeInTheDocument();
});
it('rend chaque proposition consultable sans la transformer en brouillon', () => { it("rend chaque proposition consultable sans la transformer en brouillon", () => {
render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, items: [ render(
{ identity: 'aid_first', kind: 'ai_proposal', title: 'Première', planned_for: null, state: 'published', sort_timestamp: '2026-09-16T12:00:00Z', occurrence_count: 2, provenance: 'ai_import' }, <NextSessionTile
{ identity: 'aid_expected', kind: 'ai_proposal', title: 'Attendue', planned_for: null, state: 'published', sort_timestamp: '2026-09-17T12:00:00Z', occurrence_count: 6, provenance: 'ai_import' }, size="large"
] }} pending={false} failed={false} />) preparedItems={{
const expected = document.querySelector('[data-prepared-identity="aid_expected"]') api_version: 1,
expect(expected).toHaveTextContent('Attendue') generated_at: "2026-09-17T12:00:00Z",
expect(expected).toHaveAttribute('data-prepared-identity', 'aid_expected') partial: false,
expect(document.body).not.toHaveTextContent('Brouillon d’exécution · à reprendre') items: [
}) {
identity: "aid_first",
kind: "ai_proposal",
title: "Première",
planned_for: null,
state: "published",
sort_timestamp: "2026-09-16T12:00:00Z",
occurrence_count: 2,
provenance: "ai_import",
},
{
identity: "aid_expected",
kind: "ai_proposal",
title: "Attendue",
planned_for: null,
state: "published",
sort_timestamp: "2026-09-17T12:00:00Z",
occurrence_count: 6,
provenance: "ai_import",
},
],
}}
pending={false}
failed={false}
/>,
);
const expected = document.querySelector(
'[data-prepared-identity="aid_expected"]',
);
expect(expected).toHaveTextContent("Attendue");
expect(expected).toHaveAttribute("data-prepared-identity", "aid_expected");
expect(document.body).not.toHaveTextContent(
"Brouillon d’exécution · à reprendre",
);
});
it.each(['compact', 'medium', 'large'] as const)('rend les 90 jours et les sommes factuelles en taille %s', (size) => { it.each(["compact", "medium", "large"] as const)(
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />) "rend les 90 jours et les sommes factuelles en taille %s",
const heatmap = screen.getByRole('img') (size) => {
expect(heatmap).toHaveAccessibleName(/2 jours actifs, 3 séances, 17 séries/) const { container } = render(
expect(container.querySelectorAll('.activity-day')).toHaveLength(size === 'compact' ? 35 : 90) <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', () => { it("présente l’identité comparable, le flag improved et le point legacy 0 kg", () => {
render(<ProgressionTile snapshot={dashboardFixture()} size="large" />) render(<ProgressionTile snapshot={dashboardFixture()} size="large" />);
expect(screen.getByText('Presse épaules')).toBeInTheDocument() expect(screen.getByText("Presse épaules")).toBeInTheDocument();
expect(screen.getByText('Amélioration enregistrée')).toBeInTheDocument() expect(screen.getByText("Amélioration enregistrée")).toBeInTheDocument();
expect(screen.getByText('0 kg')).toBeInTheDocument() expect(screen.getByText("0 kg")).toBeInTheDocument();
expect(screen.queryByText(/%/)).not.toBeInTheDocument() expect(screen.queryByText(/%/)).not.toBeInTheDocument();
}) });
it('réserve la courbe aux tailles medium et large', async () => { it("masque les séries incompatibles et humanise la durée pour une activité continue", () => {
const snapshot = dashboardFixture() const analysis = {
const { rerender } = render(<ProgressionTile snapshot={snapshot} size="compact" />) ...analysisFixture,
expect(screen.queryByTestId('progression-chart')).not.toBeInTheDocument() exercise: {
rerender(<ProgressionTile snapshot={snapshot} size="medium" />) ...analysisFixture.exercise!,
expect(await screen.findByTestId('progression-chart', undefined, { timeout: 5_000 })).toBeInTheDocument() name: "Marche",
expect(screen.getByRole('list', { name: 'Mesures de progression' })).toHaveTextContent('0 kg') tracking_mode: "duration" as const,
expect(screen.getByRole('list', { name: 'Mesures de progression' })).toHaveTextContent('amélioration') recording_mode: "continuous" as const,
rerender(<ProgressionTile snapshot={snapshot} size="large" />) points: [
expect(await screen.findByTestId('progression-chart', undefined, { timeout: 5_000 })).toBeInTheDocument() {
expect(screen.getByText('Premier')).toBeInTheDocument() ...analysisFixture.exercise!.points[0],
expect(screen.getByText('Dernier')).toBeInTheDocument() sets: 0,
expect(screen.getByText('Points')).toBeInTheDocument() reps: null,
}) external_load_kg: null,
external_volume_kg: null,
explicit_max_kg: null,
continuous_duration_seconds: 900,
distance_km: 1.43,
speed_kmh: 6.4,
},
],
},
};
render(
<ProgressionTile
snapshot={dashboardFixture()}
size="large"
analysis={analysis}
/>,
);
expect(screen.getByText("15 min")).toBeInTheDocument();
expect(screen.getByText("1,43 km")).toBeInTheDocument();
expect(screen.getByText("6,4 km/h")).toBeInTheDocument();
expect(screen.queryByText("Séries")).not.toBeInTheDocument();
});
it('omet toute durée fabriquée lorsque ended_at est absent et révèle les zones en grand', () => { it("réserve la courbe aux tailles medium et large", async () => {
const snapshot = dashboardFixture() const snapshot = dashboardFixture();
const { rerender } = render(<LastSessionTile snapshot={snapshot} size="medium" />) const { rerender } = render(
expect(screen.getByText('Indisponible')).toBeInTheDocument() <ProgressionTile snapshot={snapshot} size="compact" />,
if (!snapshot.data.last_session.available) throw new Error('fixture invalide') );
snapshot.data.last_session.duration_seconds = 3720 expect(screen.queryByTestId("progression-chart")).not.toBeInTheDocument();
rerender(<LastSessionTile snapshot={snapshot} size="large" />) rerender(<ProgressionTile snapshot={snapshot} size="medium" />);
expect(screen.getByText('1 h 02')).toBeInTheDocument() expect(
expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Pectoraux') await screen.findByTestId("progression-chart", undefined, {
expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Épaules') timeout: 5_000,
}) }),
).toBeInTheDocument();
expect(
screen.getByRole("list", { name: "Mesures de progression" }),
).toHaveTextContent("0 kg");
expect(
screen.getByRole("list", { name: "Mesures de progression" }),
).toHaveTextContent("amélioration");
rerender(<ProgressionTile snapshot={snapshot} size="large" />);
expect(
await screen.findByTestId("progression-chart", undefined, {
timeout: 5_000,
}),
).toBeInTheDocument();
expect(screen.getByText("Premier")).toBeInTheDocument();
expect(screen.getByText("Dernier")).toBeInTheDocument();
expect(screen.getByText("Points")).toBeInTheDocument();
});
it.each([['compact', 1], ['medium', 3], ['large', 8]] as const)('borne les MAX en taille %s à %i', (size, expected) => { it("omet toute durée fabriquée lorsque ended_at est absent et révèle les zones en grand", () => {
const { container } = render(<MaxRecordsTile snapshot={dashboardFixture()} size={size} />) const snapshot = dashboardFixture();
expect(container.querySelectorAll('.record-list li')).toHaveLength(expected) const { rerender } = render(
if (size === 'large') expect(screen.getByText('8 MAX récents affichés')).toBeInTheDocument() <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 min")).toBeInTheDocument();
expect(
screen.getByLabelText("Zones primaires travaillées"),
).toHaveTextContent("Pectoraux");
expect(
screen.getByLabelText("Zones primaires travaillées"),
).toHaveTextContent("Épaules");
});
it('encode les barres musculaires avec les séances et conserve les valeurs textuelles', () => { it.each([
const { container } = render(<MuscleDistributionTile snapshot={dashboardFixture()} size="large" />) ["compact", 1],
const chest = within(container.querySelector('.muscle-list li') as HTMLElement) ["medium", 3],
expect(chest.getByText('4 séances')).toBeInTheDocument() ["large", 8],
expect(chest.getByText(/6 occurrences · 18 séries/)).toBeInTheDocument() ] as const)("borne les MAX en taille %s à %i", (size, expected) => {
expect((container.querySelector('.muscle-track span') as HTMLElement).style.width).toBe('100%') const { container } = render(
expect(screen.getByText(/Longueur : nombre de séances/)).toBeInTheDocument() <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('affiche la silhouette en medium/large, jamais en compact, et conserve une zone non mappée dans la liste', () => { it("encode les barres musculaires avec les séances et conserve les valeurs textuelles", () => {
const snapshot = dashboardFixture() const { container } = render(
snapshot.data.muscle_distribution.primary_zones.push({ zone_id: 'full_body', label: 'Corps entier', session_count: 1, occurrence_count: 1, set_count: 0 }) <MuscleDistributionTile snapshot={dashboardFixture()} size="large" />,
const { rerender } = render(<MuscleDistributionTile snapshot={snapshot} size="compact" />) );
expect(screen.queryByLabelText('Silhouette, vue avant')).not.toBeInTheDocument() const chest = within(
rerender(<MuscleDistributionTile snapshot={snapshot} size="medium" />) container.querySelector(".muscle-list li") as HTMLElement,
expect(screen.getByLabelText('Silhouette, vue avant')).toBeInTheDocument() );
expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument() expect(chest.getByText("4 séances")).toBeInTheDocument();
expect(screen.getByText('Corps entier')).toBeInTheDocument() expect(chest.getByText(/6 occurrences · 18 séries/)).toBeInTheDocument();
rerender(<MuscleDistributionTile snapshot={snapshot} size="large" />) expect(
expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument() (container.querySelector(".muscle-track span") as HTMLElement).style
expect(screen.getByText(/Couleur : séances sur 30 jours/)).toBeInTheDocument() .width,
expect(screen.getByText('Corps entier')).toBeInTheDocument() ).toBe("100%");
}) expect(
screen.getByText(/Longueur : nombre de séances/),
).toBeInTheDocument();
});
it('gère les états indisponibles des domaines optionnels', () => { it("affiche la silhouette en medium/large, jamais en compact, et conserve une zone non mappée dans la liste", () => {
const snapshot = dashboardFixture() const snapshot = dashboardFixture();
snapshot.data.progression = { available: false, reason: 'no_comparable_performance' } snapshot.data.muscle_distribution.primary_zones.push({
snapshot.data.last_session = { available: false, reason: 'no_observable_session' } zone_id: "full_body",
snapshot.data.max_records = { available: false, records: [] } label: "Corps entier",
snapshot.data.muscle_distribution = { available: false, window_days: 30, primary_zones: [] } session_count: 1,
const { rerender } = render(<ProgressionTile snapshot={snapshot} size="compact" />) occurrence_count: 1,
expect(screen.getByText(/Aucune performance/)).toBeInTheDocument() set_count: 0,
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() const { rerender } = render(
rerender(<MuscleDistributionTile snapshot={snapshot} size="compact" />); expect(screen.getByText(/Aucune zone travaillée/)).toBeInTheDocument() <MuscleDistributionTile snapshot={snapshot} size="compact" />,
}) );
}) expect(
screen.queryByLabelText("Silhouette, vue avant"),
).not.toBeInTheDocument();
rerender(<MuscleDistributionTile snapshot={snapshot} size="medium" />);
expect(screen.getByLabelText("Silhouette, vue avant")).toBeInTheDocument();
expect(
screen.getByLabelText("Silhouette, vue arrière"),
).toBeInTheDocument();
expect(screen.getByText("Corps entier")).toBeInTheDocument();
rerender(<MuscleDistributionTile snapshot={snapshot} size="large" />);
expect(
screen.getByLabelText("Silhouette, vue arrière"),
).toBeInTheDocument();
expect(
screen.getByText(/Couleur : séances sur 30 jours/),
).toBeInTheDocument();
expect(screen.getByText("Corps entier")).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

@ -1,43 +1,70 @@
import type { DashboardSnapshot, WorkedZone } from '../api/dashboard' import type { DashboardSnapshot, WorkedZone } from "../api/dashboard";
import { lazy, Suspense, type ComponentType, type ReactNode } from 'react' import { lazy, Suspense, type ComponentType, type ReactNode } from "react";
import type { TileSize } from './dashboardLayout' import type { TileSize } from "./dashboardLayout";
import { count, formatDate, formatDateTime, formatDose, formatDuration, formatWeight } from './dashboardFormat' import {
import { BodyZoneFigure } from './BodyZoneFigure' count,
import type { PreparedItemsSnapshot } from '../api/preparedItems' formatDate,
import type { PreparedItem } from '../api/preparedItems' formatDateTime,
import { useDatePreferences } from '../presentation/DatePreferences' formatDose,
import { validDateSortValue, validTimestampValue } from '../presentation/dateFormat' formatDuration,
import type { AnalysisSnapshot } from '../api/analysis' formatWeight,
} from "./dashboardFormat";
import { BodyZoneFigure } from "./BodyZoneFigure";
import type { PreparedItemsSnapshot } from "../api/preparedItems";
import type { PreparedItem } from "../api/preparedItems";
import { useDatePreferences } from "../presentation/DatePreferences";
import {
validDateSortValue,
validTimestampValue,
} from "../presentation/dateFormat";
import type { AnalysisSnapshot } from "../api/analysis";
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart }))) const ProgressionChart = lazy(() =>
import("../charts/ProgressionChart").then((module) => ({
default: module.ProgressionChart,
})),
);
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize; analysis?: AnalysisSnapshot | null } interface TileDataProps {
snapshot: DashboardSnapshot;
size: TileSize;
analysis?: AnalysisSnapshot | null;
}
export interface NextSessionTileProps { export interface NextSessionTileProps {
size: TileSize size: TileSize;
preparedItems?: PreparedItemsSnapshot | null preparedItems?: PreparedItemsSnapshot | null;
pending?: boolean pending?: boolean;
failed?: boolean failed?: boolean;
} }
export function sortPreparedItems(items: readonly PreparedItem[]): PreparedItem[] { export function sortPreparedItems(
items: readonly PreparedItem[],
): PreparedItem[] {
return [...items].sort((left, right) => { return [...items].sort((left, right) => {
const leftDate = validDateSortValue(left.planned_for) const leftDate = validDateSortValue(left.planned_for);
const rightDate = validDateSortValue(right.planned_for) const rightDate = validDateSortValue(right.planned_for);
if (leftDate !== null && rightDate !== null && leftDate !== rightDate) { if (leftDate !== null && rightDate !== null && leftDate !== rightDate) {
return rightDate.localeCompare(leftDate, 'en') return rightDate.localeCompare(leftDate, "en");
} }
if (leftDate === null && rightDate !== null) return 1 if (leftDate === null && rightDate !== null) return 1;
if (leftDate !== null && rightDate === null) return -1 if (leftDate !== null && rightDate === null) return -1;
const leftTimestamp = validTimestampValue(left.sort_timestamp) const leftTimestamp = validTimestampValue(left.sort_timestamp);
const rightTimestamp = validTimestampValue(right.sort_timestamp) const rightTimestamp = validTimestampValue(right.sort_timestamp);
if (leftTimestamp !== null && rightTimestamp !== null && leftTimestamp !== rightTimestamp) { if (
return rightTimestamp - leftTimestamp leftTimestamp !== null &&
rightTimestamp !== null &&
leftTimestamp !== rightTimestamp
) {
return rightTimestamp - leftTimestamp;
} }
if (leftTimestamp === null && rightTimestamp !== null) return 1 if (leftTimestamp === null && rightTimestamp !== null) return 1;
if (leftTimestamp !== null && rightTimestamp === null) return -1 if (leftTimestamp !== null && rightTimestamp === null) return -1;
return `${left.kind}:${left.identity}`.localeCompare(`${right.kind}:${right.identity}`, 'en') return `${left.kind}:${left.identity}`.localeCompare(
}) `${right.kind}:${right.identity}`,
"en",
);
});
} }
export function NextSessionTile({ export function NextSessionTile({
@ -46,51 +73,94 @@ export function NextSessionTile({
pending = false, pending = false,
failed = false, failed = false,
}: NextSessionTileProps) { }: NextSessionTileProps) {
const { dateFormat } = useDatePreferences() const { dateFormat } = useDatePreferences();
if (preparedItems === null) { if (preparedItems === null) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Chargement des préparations…'} /> return (
<Unavailable
text={failed ? "Données indisponibles" : "Chargement des préparations…"}
/>
);
} }
const orderedItems = sortPreparedItems(preparedItems.items) const orderedItems = sortPreparedItems(preparedItems.items);
const item = orderedItems[0] const item = orderedItems[0];
if (item === undefined) { if (item === undefined) {
return <Unavailable text={failed ? 'Données indisponibles' : 'Aucun élément disponible'} /> return (
<Unavailable
text={failed ? "Données indisponibles" : "Aucun élément disponible"}
/>
);
} }
const proposal = item.kind === 'ai_proposal' const proposal = item.kind === "ai_proposal";
const manual = item.kind === 'manual_preparation' const manual = item.kind === "manual_preparation";
const heading = proposal const heading = proposal
? 'Proposition préparée · à valider' ? "Proposition préparée · à valider"
: manual ? 'Préparation manuelle' : manual
: item.state === 'active' ? "Préparation manuelle"
? 'Brouillon d’exécution · à reprendre' : item.state === "active"
: 'Brouillon d’exécution · en attente' ? "Brouillon d’exécution · à reprendre"
const title = item.title || (proposal ? 'Proposition sans titre' : 'Séance préparée') : "Brouillon d’exécution · en attente";
const title =
item.title || (proposal ? "Proposition sans titre" : "Séance préparée");
return ( return (
<div className="session-content"> <div className="session-content">
<p className="primary-label">{heading}</p> <p className="primary-label">{heading}</p>
<strong>{title}</strong> <strong>{title}</strong>
{size !== 'compact' && ( {size !== "compact" && (
<> <>
<dl className="fact-list horizontal"> <dl className="fact-list horizontal">
<Fact label="Éléments" value={String(item.occurrence_count)} /> <Fact label="Éléments" value={String(item.occurrence_count)} />
<Fact label="Prévue" value={item.planned_for <Fact
? <time dateTime={item.planned_for}>{formatDate(item.planned_for, dateFormat)}</time> label="Prévue"
: 'Non planifiée'} /> value={
item.planned_for ? (
<time dateTime={item.planned_for}>
{formatDate(item.planned_for, dateFormat)}
</time>
) : (
"Non planifiée"
)
}
/>
</dl> </dl>
{orderedItems.length > 1 && ( {orderedItems.length > 1 && (
<details className="prepared-items-list"> <details className="prepared-items-list">
<summary>{orderedItems.length} éléments préparés · date décroissante</summary> <summary>
{orderedItems.length} éléments préparés · date décroissante
</summary>
<ul> <ul>
{orderedItems.map((prepared) => ( {orderedItems.map((prepared) => (
<li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}> <li
<a href={`/seances/${prepared.kind === 'ai_proposal' ? 'proposal' key={`${prepared.kind}:${prepared.identity}`}
: prepared.kind === 'manual_preparation' ? 'preparation' : 'draft'}/${encodeURIComponent(prepared.identity)}`}> data-prepared-identity={prepared.identity}
{prepared.title || (prepared.kind === 'ai_proposal' ? 'Proposition sans titre' : 'Séance préparée')} >
</a>{' · '}{prepared.kind === 'ai_proposal' ? 'proposition à valider' <a
: prepared.kind === 'manual_preparation' ? 'préparation manuelle' : 'brouillon d’exécution'} href={`/seances/${
{' · '}{prepared.planned_for prepared.kind === "ai_proposal"
? <time dateTime={prepared.planned_for}> ? "proposal"
{formatDate(prepared.planned_for, dateFormat)}</time> : prepared.kind === "manual_preparation"
: 'Non planifiée'} ? "preparation"
: "draft"
}/${encodeURIComponent(prepared.identity)}`}
>
{prepared.title ||
(prepared.kind === "ai_proposal"
? "Proposition sans titre"
: "Séance préparée")}
</a>
{" · "}
{prepared.kind === "ai_proposal"
? "proposition à valider"
: prepared.kind === "manual_preparation"
? "préparation manuelle"
: "brouillon d’exécution"}
{" · "}
{prepared.planned_for ? (
<time dateTime={prepared.planned_for}>
{formatDate(prepared.planned_for, dateFormat)}
</time>
) : (
"Non planifiée"
)}
</li> </li>
))} ))}
</ul> </ul>
@ -98,109 +168,481 @@ export function NextSessionTile({
)} )}
</> </>
)} )}
{failed && <small>Dernière lecture conservée · actualisation indisponible</small>} {failed && (
<small>Dernière lecture conservée · actualisation indisponible</small>
)}
{pending && !failed && <small>Actualisation…</small>} {pending && !failed && <small>Actualisation…</small>}
<a className="tile-context-link" href={`/seances/${proposal ? 'proposal' : manual ? 'preparation' : 'draft'}/${encodeURIComponent(item.identity)}`}>Ouvrir la séance</a> <a
className="tile-context-link"
href={`/seances/${proposal ? "proposal" : manual ? "preparation" : "draft"}/${encodeURIComponent(item.identity)}`}
>
Ouvrir la séance
</a>
</div> </div>
) );
} }
export function ActivityTile({ snapshot, size, analysis }: TileDataProps) { export function ActivityTile({ snapshot, size, analysis }: TileDataProps) {
const { dateFormat } = useDatePreferences() const { dateFormat } = useDatePreferences();
const days = snapshot.data.activity.days const days = snapshot.data.activity.days;
// CONTRACT: these are direct presentation totals of Core-projected counters, // CONTRACT: these are direct presentation totals of Core-projected counters,
// never an activity or physiological-intensity score. // never an activity or physiological-intensity score.
const activeDays = days.filter((day) => day.active).length const activeDays = days.filter((day) => day.active).length;
const sessions = analysis?.overview.sessions ?? days.reduce((total, day) => total + day.session_count, 0) const sessions =
const sets = analysis?.overview.sets ?? days.reduce((total, day) => total + day.set_count, 0) analysis?.overview.sessions ??
const shown = size === 'compact' ? days.slice(-35) : days days.reduce((total, day) => total + day.session_count, 0);
return <div className="activity-content"> const sets =
<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" />}{size === 'large' && analysis?.overview.duration_seconds !== null && analysis?.overview.duration_seconds !== undefined && <Metric value={formatDuration(analysis.overview.duration_seconds)} label="durée calculable" />}</div> analysis?.overview.sets ??
<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')}.`}> days.reduce((total, day) => total + day.set_count, 0);
{shown.map((day) => <span key={day.date} className={`activity-day${day.active ? ' is-active' : ''}${day.session_count > 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)} 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" />}
{size === "large" &&
analysis?.overview.duration_seconds !== null &&
analysis?.overview.duration_seconds !== undefined && (
<Metric
value={formatDuration(analysis.overview.duration_seconds)}
label="durée calculable"
/>
)}
</div> </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
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, dateFormat)} — ${count(day.session_count, "séance")}, ${count(day.set_count, "série")}`}
/>
))}
</div> </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, analysis }: TileDataProps) { export function ProgressionTile({ snapshot, size, analysis }: TileDataProps) {
const { dateFormat } = useDatePreferences() const { dateFormat } = useDatePreferences();
if (analysis?.exercise !== null && analysis?.exercise !== undefined) { if (analysis?.exercise !== null && analysis?.exercise !== undefined) {
const exercise = analysis.exercise const exercise = analysis.exercise;
const latest = exercise.points[0] const latest = exercise.points[0];
return <div className="progression-content"><div><p className="primary-label">{exercise.name}</p><p className="secondary-label">{exercise.tracking_mode === 'reps' ? 'Répétitions' : 'Durée'}</p></div>{latest === undefined ? <Unavailable text="Aucune donnée compatible sur 30 jours" /> : <dl className="fact-list horizontal"><Fact label="Séries" value={String(latest.sets)} />{latest.reps !== null && <Fact label="Répétitions" value={String(latest.reps)} />}{latest.external_volume_kg !== null && <Fact label="Volume externe" value={formatWeight(latest.external_volume_kg)} />}{latest.explicit_max_kg !== null && <Fact label="MAX explicite" value={formatWeight(latest.explicit_max_kg)} />}</dl>}<a className="tile-context-link" href={`/analyse?section=exercise&exercise_id=${encodeURIComponent(exercise.exercise_id)}&period=30d`}>Voir dans Analyse</a></div> return (
} <div className="progression-content">
const progression = snapshot.data.progression <div>
if (!progression.available) return <Unavailable text="Aucune performance comparable disponible" /> <p className="primary-label">{exercise.name}</p>
const first = progression.points[0] <p className="secondary-label">
const last = progression.points.at(-1) {exercise.tracking_mode === "reps" ? "Répétitions" : "Durée"}
return <div className="progression-content"> </p>
<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, dateFormat)}</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 !== 'compact' && progression.points.length > 0 && <><Suspense fallback={<div className="chart-loading" role="status">Chargement de la courbe…</div>}><ProgressionChart identity={progression.identity} points={progression.points} /></Suspense><ol className="sr-only" aria-label="Mesures de progression">{progression.points.map((point, index) => <li key={`${point.session_id}-${point.timestamp}-${index}`}>{formatDateTime(point.timestamp, dateFormat)}, {formatWeight(point.weight_kg)}{point.improved ? ', amélioration' : ''}</li>)}</ol></>}
{size === 'large' && <p className="chart-legend">Identité comparable : {progression.identity.tracking_mode} · {progression.identity.load_mode}</p>}
</div> </div>
{latest === undefined ? (
<Unavailable text="Aucune donnée compatible sur 30 jours" />
) : (
<dl className="fact-list horizontal">
{exercise.recording_mode === "sets" && (
<Fact label="Séries" value={String(latest.sets)} />
)}
{latest.reps !== null && (
<Fact label="Répétitions" value={String(latest.reps)} />
)}
{(latest.continuous_duration_seconds ??
latest.set_duration_seconds) !== null && (
<Fact
label="Durée"
value={formatDuration(
latest.continuous_duration_seconds ??
latest.set_duration_seconds ??
0,
)}
/>
)}
{latest.distance_km !== null && (
<Fact
label="Distance"
value={`${latest.distance_km.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} km`}
/>
)}
{latest.speed_kmh !== null && (
<Fact
label="Vitesse"
value={`${latest.speed_kmh.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} km/h`}
/>
)}
{latest.external_volume_kg !== null && (
<Fact
label="Volume externe"
value={formatWeight(latest.external_volume_kg)}
/>
)}
{latest.explicit_max_kg !== null && (
<Fact
label="MAX explicite"
value={formatWeight(latest.explicit_max_kg)}
/>
)}
</dl>
)}
<a
className="tile-context-link"
href={`/analyse?section=exercise&exercise_id=${encodeURIComponent(exercise.exercise_id)}&period=30d`}
>
Voir dans Analyse
</a>
</div>
);
}
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, dateFormat)}</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 !== "compact" && progression.points.length > 0 && (
<>
<Suspense
fallback={
<div className="chart-loading" role="status">
Chargement de la courbe…
</div>
}
>
<ProgressionChart
identity={progression.identity}
points={progression.points}
/>
</Suspense>
<ol className="sr-only" aria-label="Mesures de progression">
{progression.points.map((point, index) => (
<li key={`${point.session_id}-${point.timestamp}-${index}`}>
{formatDateTime(point.timestamp, dateFormat)},{" "}
{formatWeight(point.weight_kg)}
{point.improved ? ", amélioration" : ""}
</li>
))}
</ol>
</>
)}
{size === "large" && (
<p className="chart-legend">
Identité comparable : {progression.identity.tracking_mode} ·{" "}
{progression.identity.load_mode}
</p>
)}
</div>
);
} }
export function LastSessionTile({ snapshot, size }: TileDataProps) { export function LastSessionTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences() const { dateFormat } = useDatePreferences();
const session = snapshot.data.last_session const session = snapshot.data.last_session;
if (!session.available) return <Unavailable text="Aucune séance observable disponible" /> if (!session.available)
return <div className="session-content"> return <Unavailable text="Aucune séance observable disponible" />;
<p className="primary-label"><time dateTime={session.started_at}>{size === 'compact' return (
<div className="session-content">
<p className="primary-label">
<time dateTime={session.started_at}>
{size === "compact"
? formatDate(session.started_at, dateFormat) ? formatDate(session.started_at, dateFormat)
: formatDateTime(session.started_at, dateFormat)}</time></p> : formatDateTime(session.started_at, dateFormat)}
<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> </time>
{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>} </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>
)}
</div>
);
} }
export function MaxRecordsTile({ snapshot, size }: TileDataProps) { export function MaxRecordsTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences() const { dateFormat } = useDatePreferences();
const records = snapshot.data.max_records.records const records = snapshot.data.max_records.records;
if (!snapshot.data.max_records.available || records.length === 0) return <Unavailable text="Aucun MAX explicite disponible" /> if (!snapshot.data.max_records.available || records.length === 0)
const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8 return <Unavailable text="Aucun MAX explicite disponible" />;
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, dateFormat)}{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> 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, dateFormat)}
{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) { export function MuscleDistributionTile({ snapshot, size }: TileDataProps) {
const zones = snapshot.data.muscle_distribution.primary_zones 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" /> 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; // INVARIANT: deterministic display order is separate from bar semantics;
// each bar length represents session_count alone. // 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 ordered = [...zones].sort(
const shown = size === 'compact' ? ordered.slice(0, 3) : ordered (a, b) =>
const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1) b.session_count - a.session_count ||
return <div className="muscle-content"> b.occurrence_count - a.occurrence_count ||
{size !== 'compact' && <div className="muscle-visual"><BodyZoneFigure zones={zones} /><p className="chart-legend">Couleur : séances sur 30 jours · maximum relatif au snapshot affiché</p></div>} a.label.localeCompare(b.label, "fr"),
<div className="muscle-details"><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><a className="tile-context-link" href="/analyse?section=body-zones&period=30d">Voir dans Analyse</a></div> );
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">
{size !== "compact" && (
<div className="muscle-visual">
<BodyZoneFigure zones={zones} />
<p className="chart-legend">
Couleur : séances sur 30 jours · maximum relatif au snapshot affiché
</p>
</div> </div>
)}
<div className="muscle-details">
<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>
<a
className="tile-context-link"
href="/analyse?section=body-zones&period=30d"
>
Voir dans Analyse
</a>
</div>
</div>
);
} }
function MuscleRow({ zone, maximum, detailed, full }: { zone: WorkedZone; maximum: number; detailed: boolean; full: boolean }) { function MuscleRow({
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> 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) { 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> 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>
);
} }
export function ProgramTile({ size, analysis }: TileDataProps) { export function ProgramTile({ size, analysis }: TileDataProps) {
const program = analysis?.active_program const program = analysis?.active_program;
if (program === null || program === undefined) return <Unavailable text="Aucun programme actif" /> if (program === null || program === undefined)
return <div className="program-progress"><strong>{program.title}</strong><div><span>{program.completed_sessions}/{program.total_sessions} séances effectuées</span></div><progress max={program.total_sessions || 1} value={program.completed_sessions} aria-label="Progression du programme actif" />{size !== 'compact' && <p>{program.next_session_title === null ? 'Programme terminé' : `Prochaine séance : ${program.next_session_title}`}</p>}<a className="tile-context-link" href="/programmes">Ouvrir Programmes</a></div> return <Unavailable text="Aucun programme actif" />;
return (
<div className="program-progress">
<strong>{program.title}</strong>
<div>
<span>
{program.completed_sessions} sur {program.total_sessions} séances
effectuées
</span>
</div>
<progress
max={program.total_sessions || 1}
value={program.completed_sessions}
aria-label="Progression du programme actif"
/>
{size !== "compact" && (
<div className="program-next">
<span>Prochaine séance</span>
<strong>{program.next_session_title ?? "Programme terminé"}</strong>
</div>
)}
<a className="tile-context-link" href="/programmes">
Ouvrir Programmes
</a>
</div>
);
} }
export function MeasurementsTile({ size, analysis }: TileDataProps) { export function MeasurementsTile({ size, analysis }: TileDataProps) {
const summaries = analysis?.measurements.summaries ?? [] const summaries = analysis?.measurements.summaries ?? [];
const visible = summaries.filter((summary) => summary.metric === 'weight' || summary.metric === 'waist').filter((summary) => summary.last !== null) const visible = summaries
if (visible.length === 0) return <Unavailable text="Aucune mensuration disponible" /> .filter(
return <div className="measurement-summary"><dl className="fact-list horizontal">{visible.map((summary) => <div key={summary.metric}><dt>{summary.metric === 'weight' ? 'Poids' : 'Tour de taille'}</dt><dd>{formatWeight(summary.last ?? 0).replace('kg', summary.unit)}{summary.delta !== null && size !== 'compact' && <small>{summary.delta > 0 ? '+' : ''}{summary.delta.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} {summary.unit}</small>}</dd></div>)}</dl><a className="tile-context-link" href="/analyse?section=measurements&metric=waist&period=30d">Voir dans Analyse</a></div> (summary) => summary.metric === "weight" || summary.metric === "waist",
)
.filter((summary) => summary.last !== null);
if (visible.length === 0)
return <Unavailable text="Aucune mensuration disponible" />;
return (
<div className="measurement-summary">
<dl className="fact-list horizontal">
{visible.map((summary) => (
<div key={summary.metric}>
<dt>{summary.metric === "weight" ? "Poids" : "Tour de taille"}</dt>
<dd>
{formatWeight(summary.last ?? 0).replace("kg", summary.unit)}
{summary.delta !== null && size !== "compact" && (
<small>
{summary.delta > 0 ? "+" : ""}
{summary.delta.toLocaleString("fr-FR", {
maximumFractionDigits: 2,
})}{" "}
{summary.unit}
</small>
)}
</dd>
</div>
))}
</dl>
<a
className="tile-context-link"
href="/analyse?section=measurements&metric=waist&period=30d"
>
Voir dans Analyse
</a>
</div>
);
} }
function Metric({ value, label }: { value: string; label: string }) { return <div className="metric"><strong>{value}</strong><span>{label}</span></div> } function Metric({ value, label }: { value: string; label: string }) {
function Fact({ label, value }: { label: string; value: ReactNode }) { return <div><dt>{label}</dt><dd>{value}</dd></div> } return (
function Unavailable({ text }: { text: string }) { return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>{text}</p></div> } <div className="metric">
<strong>{value}</strong>
<span>{label}</span>
</div>
);
}
function Fact({ label, value }: { label: string; value: ReactNode }) {
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> export type DashboardTileComponent = ComponentType<TileDataProps>;

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { formatDuration } from "./dashboardFormat";
describe("formatDuration", () => {
it.each([
[900, "15 min"],
[600, "10 min"],
[480, "8 min"],
[90, "1 min 30 s"],
[45, "45 s"],
[3720, "1 h 02 min"],
])("humanizes %i seconds as %s", (seconds, expected) => {
expect(formatDuration(seconds)).toBe(expected);
});
});

View file

@ -1,23 +1,31 @@
import type { WebDateFormat } from '../api/webPreferences' import type { WebDateFormat } from "../api/webPreferences";
import { import {
formatDate as formatPresentationDate, formatDate as formatPresentationDate,
formatDateTime as formatPresentationDateTime, formatDateTime as formatPresentationDateTime,
} from '../presentation/dateFormat' } from "../presentation/dateFormat";
const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 }) const numberFormat = new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 2,
});
export const formatDate = (value: string, format: WebDateFormat = 'fr') => export const formatDate = (value: string, format: WebDateFormat = "fr") =>
formatPresentationDate(value, format) formatPresentationDate(value, format);
export const formatDateTime = (value: string, format: WebDateFormat = 'fr') => export const formatDateTime = (value: string, format: WebDateFormat = "fr") =>
formatPresentationDateTime(value, format) formatPresentationDateTime(value, format);
export const formatWeight = (value: number) => `${numberFormat.format(value)} kg` export const formatWeight = (value: number) =>
export const formatDose = (value: number) => numberFormat.format(value) `${numberFormat.format(value)} kg`;
export const formatDose = (value: number) => numberFormat.format(value);
export function formatDuration(seconds: number): string { export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600) const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60) const minutes = Math.floor((seconds % 3600) / 60);
const remaining = seconds % 60 const remaining = seconds % 60;
if (hours > 0) return `${hours} h ${minutes.toString().padStart(2, '0')}` if (hours > 0)
if (minutes > 0) return `${minutes} min${remaining ? ` ${remaining} s` : ''}` return `${hours} h ${minutes.toString().padStart(2, "0")} min${remaining ? ` ${remaining} s` : ""}`;
return `${remaining} s` 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}` export const count = (
value: number,
singular: string,
plural = `${singular}s`,
) => `${value} ${value === 1 ? singular : plural}`;

View file

@ -1,35 +1,196 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnalysisPage } from './AnalysisPage' import { AnalysisPage } from "./AnalysisPage";
import { analysisFixture } from '../api/analysis.test' import { analysisFixture } from "../api/analysis.test";
import type { AnalysisSnapshot } from "../api/analysis";
describe('AnalysisPage', () => { describe("AnalysisPage", () => {
beforeEach(() => { beforeEach(() => {
window.history.replaceState(null, '', '/analyse?section=invalid&period=broken&metric=unknown&exercise_id=nope') document.documentElement.lang = "fr";
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(analysisFixture), { status: 200 })) window.history.replaceState(
}) null,
afterEach(() => vi.restoreAllMocks()) "",
"/analyse?section=invalid&period=broken&metric=unknown&exercise_id=nope",
);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify(analysisFixture), { status: 200 }),
);
});
afterEach(() => vi.restoreAllMocks());
it('falls back safely and changes sections, periods and metrics', async () => { it("falls back safely and changes sections, periods and metrics", async () => {
render(<AnalysisPage />) render(<AnalysisPage />);
expect(await screen.findByRole('heading', { name: 'Analyse' })).toBeInTheDocument() expect(
expect(screen.getByRole('tab', { name: 'Vue d’ensemble' })).toHaveAttribute('aria-selected', 'true') await screen.findByRole("heading", { name: "Analyse" }),
expect(screen.getByLabelText('Période')).toHaveValue('30d') ).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Période'), { target: { value: '7d' } }) expect(screen.getByRole("tab", { name: "Vue d’ensemble" })).toHaveAttribute(
await waitFor(() => expect(globalThis.fetch).toHaveBeenLastCalledWith(expect.stringContaining('period=7d'), expect.anything())) "aria-selected",
fireEvent.click(screen.getByRole('tab', { name: 'Mensurations' })) "true",
fireEvent.change(screen.getByLabelText('Mesure'), { target: { value: 'waist' } }) );
expect(window.location.search).toContain('section=measurements') expect(screen.getByLabelText("Période")).toHaveValue("30d");
}) fireEvent.change(screen.getByLabelText("Période"), {
target: { value: "7d" },
});
await waitFor(() =>
expect(globalThis.fetch).toHaveBeenLastCalledWith(
expect.stringContaining("period=7d"),
expect.anything(),
),
);
fireEvent.click(screen.getByRole("tab", { name: "Mensurations" }));
fireEvent.change(screen.getByLabelText("Mesure"), {
target: { value: "waist" },
});
expect(window.location.search).toContain("section=measurements");
});
it('supports exercise and BODY ZONE deep-link sections and English UI', async () => { it("supports exercise and BODY ZONE deep-link sections without duplicating the global language control", async () => {
window.history.replaceState(null, '', `/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`) window.history.replaceState(
render(<AnalysisPage />) null,
expect(await screen.findByRole('heading', { name: 'Presse' })).toBeInTheDocument() "",
fireEvent.click(screen.getByRole('tab', { name: 'Répartition' })) `/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`,
expect(screen.getByText(/pourcentages physiologiques/)).toBeInTheDocument() );
fireEvent.change(screen.getByLabelText('Langue'), { target: { value: 'en' } }) render(<AnalysisPage />);
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument() expect(
expect(screen.getByText(/not physiological percentages/)).toBeInTheDocument() await screen.findByRole("heading", { name: "Presse" }),
}) ).toBeInTheDocument();
}) fireEvent.click(screen.getByRole("tab", { name: "Répartition" }));
expect(screen.getByText(/pourcentages physiologiques/)).toBeInTheDocument();
expect(screen.queryByLabelText("Langue")).not.toBeInTheDocument();
expect(document.querySelector(".analysis-zone-track span")).toHaveStyle({
width: "100%",
});
expect(document.body).not.toHaveTextContent("%");
});
it("humanizes durations and hides set counts for a continuous exercise", async () => {
const continuous: AnalysisSnapshot = {
...analysisFixture,
exercises: [
{
...analysisFixture.exercises[0],
name: "Marche",
tracking_mode: "duration",
recording_mode: "continuous",
},
],
exercise: {
...analysisFixture.exercise!,
name: "Marche",
tracking_mode: "duration",
recording_mode: "continuous",
points: [
{
...analysisFixture.exercise!.points[0],
sets: 0,
reps: null,
external_load_kg: null,
external_volume_kg: null,
explicit_max_kg: null,
continuous_duration_seconds: 900,
distance_km: 1.43,
speed_kmh: 6.4,
},
],
},
};
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify(continuous), { status: 200 }),
);
window.history.replaceState(
null,
"",
`/analyse?section=exercise&exercise_id=${continuous.exercises[0].exercise_id}&period=30d`,
);
render(<AnalysisPage />);
expect(
await screen.findByRole("heading", { name: "Marche" }),
).toBeInTheDocument();
expect(screen.getByText("15 min")).toBeInTheDocument();
expect(screen.getByText("1,43 km")).toBeInTheDocument();
expect(screen.getByText("6,4 km/h")).toBeInTheDocument();
expect(screen.queryByText("Séries")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Durée" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Distance" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Vitesse" })).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: "Charge" }),
).not.toBeInTheDocument();
expect(
screen.getByText(/Durée · min · 1 points réels/),
).toBeInTheDocument();
});
it("offers only available loaded-repetition metrics and labels chart axes", async () => {
window.history.replaceState(
null,
"",
`/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`,
);
render(<AnalysisPage />);
expect(await screen.findByRole("tab", { name: "Charge" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(
screen.getByRole("tab", { name: "Répétitions" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Volume externe" }),
).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "MAX" })).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: "Durée" }),
).not.toBeInTheDocument();
expect(
screen.getByText(/Charge · kg · 1 points réels/),
).toBeInTheDocument();
expect(document.querySelectorAll(".chart-x-label")).toHaveLength(1);
expect(document.querySelectorAll(".chart-y-label")).toHaveLength(2);
});
it("renders a neutral factual measurement delta with first and last dates", async () => {
const summaries = analysisFixture.measurements.summaries.map((summary) =>
summary.metric === "weight"
? { ...summary, count: 2, first: 83.7, last: 85.1, delta: 1.4 }
: summary,
);
const measurements: AnalysisSnapshot = {
...analysisFixture,
measurements: {
selected_metric: "weight",
unit: "kg",
summaries,
points: [
{ timestamp: "2026-08-20T08:00:00Z", value: 83.7 },
{ timestamp: "2026-09-20T08:00:00Z", value: 85.1 },
],
},
};
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify(measurements), { status: 200 }),
);
window.history.replaceState(
null,
"",
"/analyse?section=measurements&metric=weight&period=30d",
);
render(<AnalysisPage />);
const delta = await screen.findByText("+1,4 kg");
expect(delta.parentElement).toHaveClass("analysis-delta");
expect(screen.getByText(/Poids · kg · 2 points réels/)).toBeInTheDocument();
expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2);
});
it("uses a factual sessions-by-date histogram in the overview", async () => {
render(<AnalysisPage />);
expect(await screen.findByText("Séances par date")).toBeInTheDocument();
expect(
screen.getByRole("img", { name: "Histogramme des séances par date" }),
).toBeInTheDocument();
expect(document.querySelector(".activity-bar")).toHaveAttribute(
"title",
expect.stringContaining("2 séance(s)"),
);
});
});

View file

@ -1,133 +1,877 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from "react";
import { ANALYSIS_PERIODS, MEASUREMENT_METRICS, fetchAnalysis, type AnalysisExercisePoint, type AnalysisPeriod, type AnalysisSnapshot, type MeasurementMetric } from '../api/analysis' import {
import { BodyZoneFigure } from '../dashboard/BodyZoneFigure' ANALYSIS_PERIODS,
import { analysisMessages, type AnalysisLanguage } from '../analysis/messages' MEASUREMENT_METRICS,
fetchAnalysis,
type AnalysisExercise,
type AnalysisExercisePoint,
type AnalysisPeriod,
type AnalysisSnapshot,
type MeasurementMetric,
} from "../api/analysis";
import { analysisMessages, type AnalysisLanguage } from "../analysis/messages";
import { BodyZoneFigure } from "../dashboard/BodyZoneFigure";
import { formatDuration } from "../dashboard/dashboardFormat";
type AnalysisSection = 'overview' | 'exercise' | 'body-zones' | 'measurements' type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements";
const sections: readonly AnalysisSection[] = ['overview', 'exercise', 'body-zones', 'measurements'] type ExerciseMetric =
const exerciseIdPattern = /^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i "duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max";
interface ChartPoint {
const metricLabels = { timestamp: string;
fr: { weight: 'Poids', neck: 'Cou', shoulders: 'Épaules', chest: 'Poitrine', waist: 'Taille', hips: 'Hanches', left_arm: 'Bras gauche', right_arm: 'Bras droit', left_forearm: 'Avant-bras gauche', right_forearm: 'Avant-bras droit', left_thigh: 'Cuisse gauche', right_thigh: 'Cuisse droite', left_calf: 'Mollet gauche', right_calf: 'Mollet droit' }, value: number;
en: { weight: 'Weight', neck: 'Neck', shoulders: 'Shoulders', chest: 'Chest', waist: 'Waist', hips: 'Hips', left_arm: 'Left arm', right_arm: 'Right arm', left_forearm: 'Left forearm', right_forearm: 'Right forearm', left_thigh: 'Left thigh', right_thigh: 'Right thigh', left_calf: 'Left calf', right_calf: 'Right calf' },
} satisfies Record<AnalysisLanguage, Record<MeasurementMetric, string>>
const zoneLabelsEn: Readonly<Record<string, string>> = {
full_body: 'Full body', upper_body: 'Upper body', chest: 'Chest', back: 'Back',
shoulders: 'Shoulders', arms: 'Arms', core: 'Core', lower_body: 'Lower body',
glutes: 'Glutes', thighs: 'Thighs', calves: 'Calves',
} }
interface ExerciseMetricDefinition {
id: ExerciseMetric;
label: string;
unit: string;
value: (point: AnalysisExercisePoint) => number | null;
display: (value: number) => string;
}
const sections: readonly AnalysisSection[] = [
"overview",
"exercise",
"body-zones",
"measurements",
];
const exerciseIdPattern =
/^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const metricLabels = {
fr: {
weight: "Poids",
neck: "Cou",
shoulders: "Épaules",
chest: "Poitrine",
waist: "Taille",
hips: "Hanches",
left_arm: "Bras gauche",
right_arm: "Bras droit",
left_forearm: "Avant-bras gauche",
right_forearm: "Avant-bras droit",
left_thigh: "Cuisse gauche",
right_thigh: "Cuisse droite",
left_calf: "Mollet gauche",
right_calf: "Mollet droit",
},
en: {
weight: "Weight",
neck: "Neck",
shoulders: "Shoulders",
chest: "Chest",
waist: "Waist",
hips: "Hips",
left_arm: "Left arm",
right_arm: "Right arm",
left_forearm: "Left forearm",
right_forearm: "Right forearm",
left_thigh: "Left thigh",
right_thigh: "Right thigh",
left_calf: "Left calf",
right_calf: "Right calf",
},
} satisfies Record<AnalysisLanguage, Record<MeasurementMetric, string>>;
const zoneLabelsEn: Readonly<Record<string, string>> = {
full_body: "Full body",
upper_body: "Upper body",
chest: "Chest",
back: "Back",
shoulders: "Shoulders",
arms: "Arms",
core: "Core",
lower_body: "Lower body",
glutes: "Glutes",
thighs: "Thighs",
calves: "Calves",
};
function safeInitialQuery() { function safeInitialQuery() {
const query = new URLSearchParams(window.location.search) const query = new URLSearchParams(window.location.search);
const sectionValue = query.get('section') const sectionValue = query.get("section");
const periodValue = query.get('period') const periodValue = query.get("period");
const metricValue = query.get('metric') const metricValue = query.get("metric");
const exerciseValue = query.get('exercise_id') const exerciseValue = query.get("exercise_id");
return { return {
section: sections.includes(sectionValue as AnalysisSection) ? sectionValue as AnalysisSection : 'overview', section: sections.includes(sectionValue as AnalysisSection)
period: ANALYSIS_PERIODS.includes(periodValue as AnalysisPeriod) ? periodValue as AnalysisPeriod : '30d', ? (sectionValue as AnalysisSection)
metric: MEASUREMENT_METRICS.includes(metricValue as MeasurementMetric) ? metricValue as MeasurementMetric : 'weight', : "overview",
exerciseId: exerciseValue !== null && exerciseIdPattern.test(exerciseValue) ? exerciseValue : undefined, period: ANALYSIS_PERIODS.includes(periodValue as AnalysisPeriod)
} ? (periodValue as AnalysisPeriod)
: "30d",
metric: MEASUREMENT_METRICS.includes(metricValue as MeasurementMetric)
? (metricValue as MeasurementMetric)
: "weight",
exerciseId:
exerciseValue !== null && exerciseIdPattern.test(exerciseValue)
? exerciseValue
: undefined,
};
} }
function formatNumber(value: number, language: AnalysisLanguage, maximumFractionDigits = 2) { const analysisLanguage = (): AnalysisLanguage =>
return new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : 'en-US', { maximumFractionDigits }).format(value) document.documentElement.lang.toLowerCase().startsWith("en") ? "en" : "fr";
const locale = (language: AnalysisLanguage) =>
language === "fr" ? "fr-FR" : "en-US";
const formatNumber = (
value: number,
language: AnalysisLanguage,
maximumFractionDigits = 2,
) =>
new Intl.NumberFormat(locale(language), { maximumFractionDigits }).format(
value,
);
const shortDate = (value: string, language: AnalysisLanguage) =>
new Intl.DateTimeFormat(locale(language), {
day: "2-digit",
month: "short",
}).format(new Date(value));
function exerciseMetrics(
exercise: AnalysisExercise,
language: AnalysisLanguage,
): ExerciseMetricDefinition[] {
const labels =
language === "fr"
? {
duration: "Durée",
distance: "Distance",
speed: "Vitesse",
load: "Charge",
reps: "Répétitions",
volume: "Volume externe",
max: "MAX",
}
: {
duration: "Duration",
distance: "Distance",
speed: "Speed",
load: "Load",
reps: "Repetitions",
volume: "External volume",
max: "MAX",
};
const candidates: ExerciseMetricDefinition[] =
exercise.tracking_mode === "duration"
? [
{
id: "duration",
label: labels.duration,
unit: "min",
value: (point) =>
point.continuous_duration_seconds ?? point.set_duration_seconds,
display: formatDuration,
},
{
id: "distance",
label: labels.distance,
unit: "km",
value: (point) => point.distance_km,
display: (value) => `${formatNumber(value, language)} km`,
},
{
id: "speed",
label: labels.speed,
unit: "km/h",
value: (point) => point.speed_kmh,
display: (value) => `${formatNumber(value, language)} km/h`,
},
]
: [
{
id: "load",
label: labels.load,
unit: "kg",
value: (point) => point.external_load_kg,
display: (value) => `${formatNumber(value, language)} kg`,
},
{
id: "reps",
label: labels.reps,
unit: language === "fr" ? "répétitions" : "repetitions",
value: (point) => point.reps,
display: (value) => formatNumber(value, language),
},
{
id: "volume",
label: labels.volume,
unit: "kg",
value: (point) => point.external_volume_kg,
display: (value) => `${formatNumber(value, language)} kg`,
},
{
id: "max",
label: labels.max,
unit: "kg",
value: (point) => point.explicit_max_kg,
display: (value) => `${formatNumber(value, language)} kg`,
},
];
return candidates.filter((metric) =>
exercise.points.some((point) => metric.value(point) !== null),
);
} }
function duration(value: number, language: AnalysisLanguage) { function chartPoints(
const hours = Math.floor(value / 3600) exercise: AnalysisExercise,
const minutes = Math.floor((value % 3600) / 60) metric: ExerciseMetricDefinition,
return hours > 0 ? `${hours} h ${minutes.toString().padStart(2, '0')} min` : `${minutes} min${language === 'en' ? '' : ''}` ): ChartPoint[] {
return exercise.points.flatMap((point) => {
const value = metric.value(point);
return value === null
? []
: [
{
timestamp: point.timestamp,
value: metric.id === "duration" ? value / 60 : value,
},
];
});
} }
function MiniLineChart({ points, unit, language, label }: { points: Array<{ timestamp: string; value: number }>; unit: string; language: AnalysisLanguage; label: string }) { export function AnalysisLineChart({
if (points.length === 0) return null points,
const ordered = [...points].reverse() unit,
const values = ordered.map((point) => point.value) language,
const minimum = Math.min(...values) label,
const maximum = Math.max(...values) displayValue,
const span = maximum - minimum || 1 }: {
const polyline = ordered.map((point, index) => `${ordered.length === 1 ? 50 : (index / (ordered.length - 1)) * 100},${36 - ((point.value - minimum) / span) * 30}`).join(' ') points: ChartPoint[];
return <figure className="analysis-chart"><svg viewBox="0 0 100 40" role="img" aria-label={`${label}: ${points.length} points`} preserveAspectRatio="none"><polyline points={polyline} fill="none" vectorEffect="non-scaling-stroke" /><g>{ordered.map((point, index) => <circle key={`${point.timestamp}-${index}`} cx={ordered.length === 1 ? 50 : (index / (ordered.length - 1)) * 100} cy={36 - ((point.value - minimum) / span) * 30} r="1.5"><title>{new Intl.DateTimeFormat(language === 'fr' ? 'fr-FR' : 'en-US').format(new Date(point.timestamp))} · {formatNumber(point.value, language)} {unit}</title></circle>)}</g></svg><figcaption>{formatNumber(minimum, language)}–{formatNumber(maximum, language)} {unit} · {points.length} points · no interpolation</figcaption></figure> unit: string;
language: AnalysisLanguage;
label: string;
displayValue?: (value: number) => string;
}) {
if (points.length === 0) return null;
const ordered = [...points].sort(
(left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
);
const values = ordered.map((point) => point.value);
const minimum = Math.min(...values);
const maximum = Math.max(...values);
const span = maximum - minimum || Math.max(Math.abs(maximum) * 0.1, 1);
const low = minimum - span * 0.12;
const high = maximum + span * 0.12;
const scaleX = (index: number) =>
ordered.length === 1 ? 55 : 10 + (index / (ordered.length - 1)) * 96;
const scaleY = (value: number) => 62 - ((value - low) / (high - low)) * 50;
const polyline = ordered
.map((point, index) => `${scaleX(index)},${scaleY(point.value)}`)
.join(" ");
const formatValue =
displayValue ??
((value: number) => `${formatNumber(value, language)} ${unit}`);
return (
<figure className="analysis-chart">
<div className="analysis-chart-heading">
<strong>{label}</strong>
<span>{unit}</span>
</div>
<svg
viewBox="0 0 112 78"
role="img"
aria-label={`${label}: ${points.length} points`}
preserveAspectRatio="none"
>
<line className="chart-axis" x1="10" y1="6" x2="10" y2="62" />
<line className="chart-axis" x1="10" y1="62" x2="108" y2="62" />
<text className="chart-y-label" x="8" y="10" textAnchor="end">
{formatNumber(maximum, language)}
</text>
<text className="chart-y-label" x="8" y="62" textAnchor="end">
{formatNumber(minimum, language)}
</text>
{ordered.length > 1 && (
<polyline
points={polyline}
fill="none"
vectorEffect="non-scaling-stroke"
/>
)}
{ordered.map((point, index) => (
<g key={`${point.timestamp}-${index}`}>
<circle cx={scaleX(index)} cy={scaleY(point.value)} r="1.05">
<title>
{shortDate(point.timestamp, language)} ·{" "}
{formatValue(unit === "min" ? point.value * 60 : point.value)}
</title>
</circle>
{(index === 0 || index === ordered.length - 1) && (
<text
className="chart-x-label"
x={scaleX(index)}
y="73"
textAnchor={index === 0 ? "start" : "end"}
>
{shortDate(point.timestamp, language)}
</text>
)}
</g>
))}
</svg>
<figcaption>
{label} · {unit} · {points.length}{" "}
{language === "fr"
? "points réels, sans interpolation"
: "real points, no interpolation"}
</figcaption>
</figure>
);
} }
function ExerciseFacts({ points, language }: { points: AnalysisExercisePoint[]; language: AnalysisLanguage }) { function ActivityHistogram({
const t = analysisMessages[language] points,
if (points.length === 0) return <p className="analysis-empty">{t.noExerciseData}</p> language,
const latest = points[0] }: {
return <> points: AnalysisSnapshot["activity"];
language: AnalysisLanguage;
}) {
if (points.length === 0) return null;
const ordered = [...points].sort((left, right) =>
left.date.localeCompare(right.date),
);
const maximum = Math.max(...ordered.map((point) => point.sessions), 1);
return (
<figure className="activity-histogram">
<div className="analysis-chart-heading">
<strong>
{language === "fr" ? "Séances par date" : "Sessions by date"}
</strong>
<span>{language === "fr" ? "séances" : "sessions"}</span>
</div>
<div
className="activity-bars"
role="img"
aria-label={
language === "fr"
? "Histogramme des séances par date"
: "Sessions-by-date histogram"
}
>
{ordered.map((point, index) => (
<div className="activity-bar-column" key={point.date}>
<span className="activity-bar-value">{point.sessions}</span>
<span
className="activity-bar"
style={{
height: `${Math.max(8, (point.sessions / maximum) * 100)}%`,
}}
title={`${shortDate(`${point.date}T00:00:00Z`, language)} · ${point.sessions} ${language === "fr" ? "séance(s)" : "session(s)"}`}
/>
{(ordered.length <= 10 ||
index === 0 ||
index === ordered.length - 1) && (
<time dateTime={point.date}>
{shortDate(`${point.date}T00:00:00Z`, language)}
</time>
)}
</div>
))}
</div>
</figure>
);
}
function ExerciseFacts({
exercise,
language,
selectedMetric,
onMetricChange,
detailed = false,
}: {
exercise: AnalysisExercise;
language: AnalysisLanguage;
selectedMetric: ExerciseMetric | null;
onMetricChange: (metric: ExerciseMetric) => void;
detailed?: boolean;
}) {
const t = analysisMessages[language];
if (exercise.points.length === 0)
return <p className="analysis-empty">{t.noExerciseData}</p>;
const latest = exercise.points[0];
const metrics = exerciseMetrics(exercise, language);
const activeMetric =
metrics.find((metric) => metric.id === selectedMetric) ?? metrics[0];
return (
<>
<dl className="analysis-facts"> <dl className="analysis-facts">
<Fact label={t.sets} value={latest.sets} language={language} /> {exercise.recording_mode === "sets" && (
{latest.reps !== null && <Fact label={t.reps} value={latest.reps} language={language} />} <Fact label={t.sets} value={formatNumber(latest.sets, language)} />
{latest.external_load_kg !== null && <Fact label={language === 'fr' ? 'Charge' : 'Load'} value={latest.external_load_kg} unit="kg" language={language} />} )}
{latest.external_volume_kg !== null && <Fact label={t.volume} value={latest.external_volume_kg} unit="kg" language={language} />} {latest.reps !== null && (
{(latest.set_duration_seconds ?? latest.continuous_duration_seconds) !== null && <Fact label={t.duration} value={latest.set_duration_seconds ?? latest.continuous_duration_seconds ?? 0} unit="s" language={language} />} <Fact label={t.reps} value={formatNumber(latest.reps, language)} />
{latest.distance_km !== null && <Fact label={t.distance} value={latest.distance_km} unit="km" language={language} />} )}
{latest.speed_kmh !== null && <Fact label={t.speed} value={latest.speed_kmh} unit="km/h" language={language} />} {latest.external_load_kg !== null && (
{latest.explicit_max_kg !== null && <Fact label={t.max} value={latest.explicit_max_kg} unit="kg" language={language} />} <Fact
{latest.load_mode === 'none' && latest.reps !== null && <Fact label={t.frequency} value={points.length} language={language} />} label={language === "fr" ? "Charge" : "Load"}
value={`${formatNumber(latest.external_load_kg, language)} kg`}
/>
)}
{latest.external_volume_kg !== null && (
<Fact
label={t.volume}
value={`${formatNumber(latest.external_volume_kg, language)} kg`}
/>
)}
{(latest.set_duration_seconds ?? latest.continuous_duration_seconds) !==
null && (
<Fact
label={t.duration}
value={formatDuration(
latest.set_duration_seconds ??
latest.continuous_duration_seconds ??
0,
)}
/>
)}
{latest.distance_km !== null && (
<Fact
label={t.distance}
value={`${formatNumber(latest.distance_km, language)} km`}
/>
)}
{latest.speed_kmh !== null && (
<Fact
label={t.speed}
value={`${formatNumber(latest.speed_kmh, language)} km/h`}
/>
)}
{latest.explicit_max_kg !== null && (
<Fact
label={t.max}
value={`${formatNumber(latest.explicit_max_kg, language)} kg`}
/>
)}
{latest.load_mode === "none" && latest.reps !== null && (
<Fact
label={t.frequency}
value={formatNumber(exercise.points.length, language)}
/>
)}
</dl> </dl>
<MiniLineChart points={points.map((point) => ({ timestamp: point.timestamp, value: point.explicit_max_kg ?? point.external_volume_kg ?? point.reps ?? point.continuous_duration_seconds ?? point.set_duration_seconds ?? 0 })).filter((point) => point.value > 0)} unit={latest.explicit_max_kg !== null || latest.external_volume_kg !== null ? 'kg' : latest.reps !== null ? 'reps' : 's'} language={language} label={t.exercise} /> {activeMetric && (
<>
{detailed && metrics.length > 1 && (
<div
className="analysis-metric-tabs"
role="tablist"
aria-label={
language === "fr" ? "Métrique du graphique" : "Chart metric"
}
>
{metrics.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={item.id === activeMetric.id}
onClick={() => onMetricChange(item.id)}
>
{item.label}
</button>
))}
</div>
)}
<AnalysisLineChart
points={chartPoints(exercise, activeMetric)}
unit={activeMetric.unit}
language={language}
label={activeMetric.label}
displayValue={activeMetric.display}
/>
</> </>
)}
</>
);
} }
function Fact({ label, value, unit, language }: { label: string; value: number; unit?: string; language: AnalysisLanguage }) { function Fact({ label, value }: { label: string; value: string }) {
return <div><dt>{label}</dt><dd>{formatNumber(value, language)}{unit ? ` ${unit}` : ''}</dd></div> return (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
} }
export function AnalysisPage() { export function AnalysisPage() {
const initial = useMemo(safeInitialQuery, []) const initial = useMemo(safeInitialQuery, []);
const [section, setSection] = useState<AnalysisSection>(initial.section) const language = analysisLanguage();
const [period, setPeriod] = useState<AnalysisPeriod>(initial.period) const [section, setSection] = useState<AnalysisSection>(initial.section);
const [metric, setMetric] = useState<MeasurementMetric>(initial.metric) const [period, setPeriod] = useState<AnalysisPeriod>(initial.period);
const [exerciseId, setExerciseId] = useState<string | undefined>(initial.exerciseId) const [metric, setMetric] = useState<MeasurementMetric>(initial.metric);
const [language, setLanguage] = useState<AnalysisLanguage>('fr') const [exerciseId, setExerciseId] = useState<string | undefined>(
const [snapshot, setSnapshot] = useState<AnalysisSnapshot | null>(null) initial.exerciseId,
const [pending, setPending] = useState(true) );
const [failed, setFailed] = useState(false) const [exerciseMetric, setExerciseMetric] = useState<ExerciseMetric | null>(
const t = analysisMessages[language] null,
);
const [snapshot, setSnapshot] = useState<AnalysisSnapshot | null>(null);
const [pending, setPending] = useState(true);
const [failed, setFailed] = useState(false);
const t = analysisMessages[language];
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController();
setPending(true) setPending(true);
fetchAnalysis({ period, exerciseId, metric }, controller.signal).then((value) => { fetchAnalysis({ period, exerciseId, metric }, controller.signal)
setSnapshot(value); setFailed(false) .then((value) => {
if (exerciseId === undefined && value.exercise !== null) setExerciseId(value.exercise.exercise_id) setSnapshot(value);
}).catch(() => { if (!controller.signal.aborted) setFailed(true) }).finally(() => { if (!controller.signal.aborted) setPending(false) }) setFailed(false);
return () => controller.abort() if (exerciseId === undefined && value.exercise !== null)
}, [period, exerciseId, metric]) setExerciseId(value.exercise.exercise_id);
})
.catch(() => {
if (!controller.signal.aborted) setFailed(true);
})
.finally(() => {
if (!controller.signal.aborted) setPending(false);
});
return () => controller.abort();
}, [period, exerciseId, metric]);
function updateUrl(nextSection = section, nextPeriod = period, nextExercise = exerciseId, nextMetric = metric) { function updateUrl(
const query = new URLSearchParams({ section: nextSection, period: nextPeriod }) nextSection = section,
if (nextExercise) query.set('exercise_id', nextExercise) nextPeriod = period,
if (nextMetric !== 'weight' || nextSection === 'measurements') query.set('metric', nextMetric) nextExercise = exerciseId,
window.history.replaceState(null, '', `/analyse?${query}`) nextMetric = metric,
) {
const query = new URLSearchParams({
section: nextSection,
period: nextPeriod,
});
if (nextExercise) query.set("exercise_id", nextExercise);
if (nextMetric !== "weight" || nextSection === "measurements")
query.set("metric", nextMetric);
window.history.replaceState(null, "", `/analyse?${query}`);
}
function chooseSection(value: AnalysisSection) {
setSection(value);
updateUrl(value);
}
function choosePeriod(value: AnalysisPeriod) {
setPeriod(value);
updateUrl(section, value);
}
function chooseExercise(value: string) {
setExerciseId(value);
setExerciseMetric(null);
updateUrl("exercise", period, value);
setSection("exercise");
}
function chooseMetric(value: MeasurementMetric) {
setMetric(value);
updateUrl("measurements", period, exerciseId, value);
setSection("measurements");
} }
function chooseSection(value: AnalysisSection) { setSection(value); updateUrl(value) }
function choosePeriod(value: AnalysisPeriod) { setPeriod(value); updateUrl(section, value) }
function chooseExercise(value: string) { setExerciseId(value); updateUrl('exercise', period, value); setSection('exercise') }
function chooseMetric(value: MeasurementMetric) { setMetric(value); updateUrl('measurements', period, exerciseId, value); setSection('measurements') }
const selectedSummary = snapshot?.measurements.summaries.find((item) => item.metric === metric) const selectedSummary = snapshot?.measurements.summaries.find(
const zoneFigure = snapshot?.body_zones.map((zone) => ({ zone_id: zone.zone_id, label: language === 'en' ? zoneLabelsEn[zone.zone_id] ?? zone.zone_id : zone.label, session_count: zone.exposures, occurrence_count: zone.exposures, set_count: zone.associated_sets })) ?? [] (item) => item.metric === metric,
return <section className="page analysis-page" aria-labelledby="page-title"> );
<div className="page-heading"><div><p className="eyebrow">{t.eyebrow}</p><h1 id="page-title">{t.title}</h1></div><label className="analysis-language">{t.language}<select value={language} onChange={(event) => setLanguage(event.target.value as AnalysisLanguage)}><option value="fr">Français</option><option value="en">English</option></select></label></div> const zoneFigure =
<div className="analysis-toolbar"><div className="analysis-tabs" role="tablist" aria-label={t.title}>{sections.map((id) => <button key={id} type="button" role="tab" aria-selected={section === id} onClick={() => chooseSection(id)}>{id === 'overview' ? t.overview : id === 'exercise' ? t.exercises : id === 'body-zones' ? t.distribution : t.measurements}</button>)}</div><label>{t.period}<select aria-label={t.period} value={period} onChange={(event) => choosePeriod(event.target.value as AnalysisPeriod)}>{ANALYSIS_PERIODS.map((value) => <option key={value} value={value}>{t.periods[value]}</option>)}</select></label></div> snapshot?.body_zones.map((zone) => ({
zone_id: zone.zone_id,
label:
language === "en"
? (zoneLabelsEn[zone.zone_id] ?? zone.zone_id)
: zone.label,
session_count: zone.exposures,
occurrence_count: zone.exposures,
set_count: zone.associated_sets,
})) ?? [];
const maximumExposure = Math.max(
1,
...(snapshot?.body_zones.map((zone) => zone.exposures) ?? []),
);
return (
<section className="page analysis-page" aria-labelledby="page-title">
<div className="page-heading">
<div>
<p className="eyebrow">{t.eyebrow}</p>
<h1 id="page-title">{t.title}</h1>
</div>
</div>
<div className="analysis-toolbar">
<div className="analysis-tabs" role="tablist" aria-label={t.title}>
{sections.map((id) => (
<button
key={id}
type="button"
role="tab"
aria-selected={section === id}
onClick={() => chooseSection(id)}
>
{id === "overview"
? t.overview
: id === "exercise"
? t.exercises
: id === "body-zones"
? t.distribution
: t.measurements}
</button>
))}
</div>
<label>
{t.period}
<select
aria-label={t.period}
value={period}
onChange={(event) =>
choosePeriod(event.target.value as AnalysisPeriod)
}
>
{ANALYSIS_PERIODS.map((value) => (
<option key={value} value={value}>
{t.periods[value]}
</option>
))}
</select>
</label>
</div>
{pending && snapshot === null && <p role="status">{t.loading}</p>} {pending && snapshot === null && <p role="status">{t.loading}</p>}
{failed && snapshot === null && <p className="error-panel" role="alert">{t.failed}</p>} {failed && snapshot === null && (
{snapshot !== null && <> <p className="error-panel" role="alert">
{snapshot.meta.partial && <p className="analysis-note">{t.partial}</p>} {t.failed}
{section === 'overview' && <div className="analysis-section-grid"> </p>
<article className="analysis-panel"><h2>{t.overview}</h2><dl className="analysis-facts"><Fact label={t.sessions} value={snapshot.overview.sessions} language={language} /><Fact label={t.sets} value={snapshot.overview.sets} language={language} />{snapshot.overview.duration_seconds !== null && <div><dt>{t.duration}</dt><dd>{duration(snapshot.overview.duration_seconds, language)}</dd></div>}</dl>{snapshot.overview.sessions === 0 && <p className="analysis-empty">{t.noData}</p>}<MiniLineChart points={snapshot.activity.map((day) => ({ timestamp: `${day.date}T00:00:00Z`, value: day.sessions }))} unit={t.sessions.toLowerCase()} language={language} label={t.sessions} /></article> )}
<article className="analysis-panel"><h2>{t.activeProgram}</h2>{snapshot.active_program === null ? <p className="analysis-empty">{t.noProgram}</p> : <><h3>{snapshot.active_program.title}</h3><p>{snapshot.active_program.completed_sessions}/{snapshot.active_program.total_sessions}</p><progress max={snapshot.active_program.total_sessions || 1} value={snapshot.active_program.completed_sessions} aria-label={t.programProgress} />{snapshot.active_program.next_session_title && <p>{t.nextSession}: {snapshot.active_program.next_session_title}</p>}<a href="/programmes">Programmes</a></>}</article> {snapshot !== null && (
<article className="analysis-panel"><h2>{t.distribution}</h2>{zoneFigure.length === 0 ? <p className="analysis-empty">{t.noData}</p> : <BodyZoneFigure zones={zoneFigure} language={language} />}<button className="analysis-link" type="button" onClick={() => chooseSection('body-zones')}>{t.distribution}</button></article> <>
<article className="analysis-panel"><h2>{t.exercises}</h2>{snapshot.exercise === null ? <p className="analysis-empty">{t.noExercise}</p> : <><h3>{snapshot.exercise.name}</h3><ExerciseFacts points={snapshot.exercise.points} language={language} /></>}</article> {snapshot.meta.partial && (
</div>} <p className="analysis-note">{t.partial}</p>
{section === 'exercise' && <article className="analysis-panel analysis-wide"><label className="analysis-picker">{t.exercise}<select value={snapshot.exercise?.exercise_id ?? ''} onChange={(event) => chooseExercise(event.target.value)}><option value="" disabled>{t.noExercise}</option>{snapshot.exercises.map((exercise) => <option key={exercise.exercise_id} value={exercise.exercise_id}>{exercise.name}</option>)}</select></label>{snapshot.exercise === null ? <p className="analysis-empty">{t.noExercise}</p> : <><h2>{snapshot.exercise.name}</h2><ExerciseFacts points={snapshot.exercise.points} language={language} /></>}</article>} )}
{section === 'body-zones' && <article className="analysis-panel analysis-wide"><h2>{t.exposures}</h2><p className="analysis-note">{t.zonesHelp}</p>{zoneFigure.length === 0 ? <p className="analysis-empty">{t.noData}</p> : <div className="analysis-zones"><BodyZoneFigure zones={zoneFigure} language={language} /><ul>{snapshot.body_zones.map((zone) => <li key={zone.zone_id}><strong>{language === 'en' ? zoneLabelsEn[zone.zone_id] ?? zone.zone_id : zone.label}</strong><span>{zone.exposures} {t.exposures.toLowerCase()} · {zone.associated_sets} {t.sets.toLowerCase()}</span></li>)}</ul></div>}</article>} {section === "overview" && (
{section === 'measurements' && <article className="analysis-panel analysis-wide"><label className="analysis-picker">{t.metric}<select value={metric} onChange={(event) => chooseMetric(event.target.value as MeasurementMetric)}>{MEASUREMENT_METRICS.map((value) => <option key={value} value={value}>{metricLabels[language][value]}</option>)}</select></label><h2>{metricLabels[language][metric]}</h2>{selectedSummary === undefined || selectedSummary.count === 0 ? <p className="analysis-empty">{t.noData}</p> : <><dl className="analysis-facts"><div><dt>{t.first}</dt><dd>{selectedSummary.first === null ? '—' : `${formatNumber(selectedSummary.first, language)} ${selectedSummary.unit}`}</dd></div><div><dt>{t.last}</dt><dd>{selectedSummary.last === null ? '—' : `${formatNumber(selectedSummary.last, language)} ${selectedSummary.unit}`}</dd></div><div><dt>{t.delta}</dt><dd>{selectedSummary.delta === null ? '—' : `${selectedSummary.delta > 0 ? '+' : ''}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}</dd></div></dl>{selectedSummary.count === 1 && <p className="analysis-note">{t.onePoint}</p>}<MiniLineChart points={snapshot.measurements.points} unit={selectedSummary.unit} language={language} label={metricLabels[language][metric]} /></>}</article>} <div className="analysis-section-grid">
</>} <article className="analysis-panel">
</section> <h2>{t.overview}</h2>
<dl className="analysis-facts">
<Fact
label={t.sessions}
value={formatNumber(snapshot.overview.sessions, language)}
/>
<Fact
label={t.sets}
value={formatNumber(snapshot.overview.sets, language)}
/>
{snapshot.overview.duration_seconds !== null && (
<Fact
label={t.duration}
value={formatDuration(snapshot.overview.duration_seconds)}
/>
)}
</dl>
{snapshot.overview.sessions === 0 && (
<p className="analysis-empty">{t.noData}</p>
)}
<ActivityHistogram
points={snapshot.activity}
language={language}
/>
</article>
<article className="analysis-panel">
<h2>{t.activeProgram}</h2>
{snapshot.active_program === null ? (
<p className="analysis-empty">{t.noProgram}</p>
) : (
<div className="analysis-program">
<h3>{snapshot.active_program.title}</h3>
<div className="analysis-program-count">
<strong>
{snapshot.active_program.completed_sessions}
</strong>{" "}
{language === "fr"
? `sur ${snapshot.active_program.total_sessions} séances`
: `of ${snapshot.active_program.total_sessions} sessions`}
</div>
<progress
max={snapshot.active_program.total_sessions || 1}
value={snapshot.active_program.completed_sessions}
aria-label={t.programProgress}
/>
{snapshot.active_program.next_session_title && (
<div className="analysis-next-session">
<span>{t.nextSession}</span>
<strong>
{snapshot.active_program.next_session_title}
</strong>
</div>
)}
<a className="analysis-action" href="/programmes">
Programmes
</a>
</div>
)}
</article>
<article className="analysis-panel">
<h2>{t.distribution}</h2>
{zoneFigure.length === 0 ? (
<p className="analysis-empty">{t.noData}</p>
) : (
<BodyZoneFigure zones={zoneFigure} language={language} />
)}
<button
className="analysis-link"
type="button"
onClick={() => chooseSection("body-zones")}
>
{t.distribution}
</button>
</article>
<article className="analysis-panel">
<h2>{t.exercises}</h2>
{snapshot.exercise === null ? (
<p className="analysis-empty">{t.noExercise}</p>
) : (
<>
<h3>{snapshot.exercise.name}</h3>
<ExerciseFacts
exercise={snapshot.exercise}
language={language}
selectedMetric={exerciseMetric}
onMetricChange={setExerciseMetric}
/>
</>
)}
</article>
</div>
)}
{section === "exercise" && (
<article className="analysis-panel analysis-wide">
<label className="analysis-picker">
{t.exercise}
<select
value={snapshot.exercise?.exercise_id ?? ""}
onChange={(event) => chooseExercise(event.target.value)}
>
<option value="" disabled>
{t.noExercise}
</option>
{snapshot.exercises.map((exercise) => (
<option
key={exercise.exercise_id}
value={exercise.exercise_id}
>
{exercise.name}
</option>
))}
</select>
</label>
{snapshot.exercise === null ? (
<p className="analysis-empty">{t.noExercise}</p>
) : (
<>
<h2>{snapshot.exercise.name}</h2>
<ExerciseFacts
exercise={snapshot.exercise}
language={language}
selectedMetric={exerciseMetric}
onMetricChange={setExerciseMetric}
detailed
/>
</>
)}
</article>
)}
{section === "body-zones" && (
<article className="analysis-panel analysis-wide">
<h2>{t.exposures}</h2>
<p className="analysis-note">{t.zonesHelp}</p>
{zoneFigure.length === 0 ? (
<p className="analysis-empty">{t.noData}</p>
) : (
<div className="analysis-zones">
<BodyZoneFigure zones={zoneFigure} language={language} />
<ul>
{snapshot.body_zones.map((zone) => (
<li key={zone.zone_id}>
<div className="analysis-zone-heading">
<strong>
{language === "en"
? (zoneLabelsEn[zone.zone_id] ?? zone.zone_id)
: zone.label}
</strong>
<span>
{zone.exposures} {t.exposures.toLowerCase()} ·{" "}
{zone.associated_sets} {t.sets.toLowerCase()}
</span>
</div>
<div
className="analysis-zone-track"
aria-label={`${zone.exposures} ${t.exposures.toLowerCase()}`}
>
<span
style={{
width: `${(zone.exposures / maximumExposure) * 100}%`,
}}
/>
</div>
</li>
))}
</ul>
</div>
)}
</article>
)}
{section === "measurements" && (
<article className="analysis-panel analysis-wide">
<label className="analysis-picker">
{t.metric}
<select
value={metric}
onChange={(event) =>
chooseMetric(event.target.value as MeasurementMetric)
}
>
{MEASUREMENT_METRICS.map((value) => (
<option key={value} value={value}>
{metricLabels[language][value]}
</option>
))}
</select>
</label>
<h2>{metricLabels[language][metric]}</h2>
{selectedSummary === undefined || selectedSummary.count === 0 ? (
<p className="analysis-empty">{t.noData}</p>
) : (
<>
<dl className="analysis-facts">
<Fact
label={t.first}
value={
selectedSummary.first === null
? "—"
: `${formatNumber(selectedSummary.first, language)} ${selectedSummary.unit}`
}
/>
<Fact
label={t.last}
value={
selectedSummary.last === null
? "—"
: `${formatNumber(selectedSummary.last, language)} ${selectedSummary.unit}`
}
/>
<div className="analysis-delta">
<dt>{t.delta}</dt>
<dd>
{selectedSummary.delta === null
? "—"
: `${selectedSummary.delta > 0 ? "+" : ""}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}
</dd>
</div>
</dl>
{selectedSummary.count === 1 && (
<p className="analysis-note">{t.onePoint}</p>
)}
<AnalysisLineChart
points={snapshot.measurements.points}
unit={selectedSummary.unit}
language={language}
label={metricLabels[language][metric]}
/>
</>
)}
</article>
)}
</>
)}
</section>
);
} }

File diff suppressed because it is too large Load diff