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,
keyboard focus, and responsive charts are covered without estimating 1RM,
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

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) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| 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 |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_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 { describe, expect, it } from 'vitest'
import { dashboardFixture } from '../test/dashboardFixtures'
import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile } from './DashboardTiles'
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { dashboardFixture } from "../test/dashboardFixtures";
import {
ActivityTile,
CardioTile,
LastSessionTile,
MaxRecordsTile,
MuscleDistributionTile,
NextSessionTile,
ProgressionTile,
} from "./DashboardTiles";
import { analysisFixture } from "../api/analysis.test";
describe('tuiles Dashboard alimentées par le contrat', () => {
it('présente les indisponibilités sans exposer les raisons techniques ni inventer de faits', () => {
const snapshot = dashboardFixture()
const { rerender } = render(<NextSessionTile preparedItems={{api_version:1,generated_at:'2026-09-17T12:00:00Z',partial:false,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)
})
describe("tuiles Dashboard alimentées par le contrat", () => {
it("présente les indisponibilités sans exposer les raisons techniques ni inventer de faits", () => {
const snapshot = dashboardFixture();
const { rerender } = render(
<NextSessionTile
preparedItems={{
api_version: 1,
generated_at: "2026-09-17T12:00:00Z",
partial: false,
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 = {
api_version: 1 as const,
generated_at: '2026-09-17T12:00:00Z',
generated_at: "2026-09-17T12:00:00Z",
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(
<NextSessionTile size="large" preparedItems={proposal} pending={false} 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)
<NextSessionTile
size="large"
preparedItems={proposal}
pending={false}
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} />)
expect(screen.getByText('Brouillon d’exécution · à reprendre')).toBeInTheDocument()
})
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}
/>,
);
expect(
screen.getByText("Brouillon d’exécution · à reprendre"),
).toBeInTheDocument();
});
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 />)
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("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
/>,
);
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', () => {
render(<NextSessionTile size="large" preparedItems={{ api_version: 1, generated_at: '2026-09-17T12:00:00Z', partial: false, 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("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: [
{
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) => {
const { container } = render(<ActivityTile snapshot={dashboardFixture()} size={size} />)
const heatmap = screen.getByRole('img')
expect(heatmap).toHaveAccessibleName(/2 jours actifs, 3 séances, 17 séries/)
expect(container.querySelectorAll('.activity-day')).toHaveLength(size === 'compact' ? 35 : 90)
})
it.each(["compact", "medium", "large"] as const)(
"rend les 90 jours et les sommes factuelles en taille %s",
(size) => {
const { container } = render(
<ActivityTile snapshot={dashboardFixture()} size={size} />,
);
const heatmap = screen.getByRole("img");
expect(heatmap).toHaveAccessibleName(
/2 jours actifs, 3 séances, 17 séries/,
);
expect(container.querySelectorAll(".activity-day")).toHaveLength(
size === "compact" ? 35 : 90,
);
},
);
it('présente l’identité comparable, le flag improved et le point legacy 0 kg', () => {
render(<ProgressionTile snapshot={dashboardFixture()} size="large" />)
expect(screen.getByText('Presse épaules')).toBeInTheDocument()
expect(screen.getByText('Amélioration enregistrée')).toBeInTheDocument()
expect(screen.getByText('0 kg')).toBeInTheDocument()
expect(screen.queryByText(/%/)).not.toBeInTheDocument()
})
it("présente l’identité comparable, le flag improved et le point legacy 0 kg", () => {
render(<ProgressionTile snapshot={dashboardFixture()} size="large" />);
expect(screen.getByText("Presse épaules")).toBeInTheDocument();
expect(screen.getByText("Amélioration enregistrée")).toBeInTheDocument();
expect(screen.getByText("0 kg")).toBeInTheDocument();
expect(screen.queryByText(/%/)).not.toBeInTheDocument();
});
it('réserve la courbe aux tailles medium et large', async () => {
const snapshot = dashboardFixture()
const { rerender } = render(<ProgressionTile snapshot={snapshot} size="compact" />)
expect(screen.queryByTestId('progression-chart')).not.toBeInTheDocument()
rerender(<ProgressionTile snapshot={snapshot} size="medium" />)
expect(await screen.findByTestId('progression-chart', undefined, { 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("masque les séries incompatibles et humanise la durée pour une activité continue", () => {
const analysis = {
...analysisFixture,
exercise: {
...analysisFixture.exercise!,
name: "Marche",
tracking_mode: "duration" as const,
recording_mode: "continuous" as const,
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,
},
],
},
};
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', () => {
const snapshot = dashboardFixture()
const { rerender } = render(<LastSessionTile snapshot={snapshot} size="medium" />)
expect(screen.getByText('Indisponible')).toBeInTheDocument()
if (!snapshot.data.last_session.available) throw new Error('fixture invalide')
snapshot.data.last_session.duration_seconds = 3720
rerender(<LastSessionTile snapshot={snapshot} size="large" />)
expect(screen.getByText('1 h 02')).toBeInTheDocument()
expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Pectoraux')
expect(screen.getByLabelText('Zones primaires travaillées')).toHaveTextContent('Épaules')
})
it("réserve la courbe aux tailles medium et large", async () => {
const snapshot = dashboardFixture();
const { rerender } = render(
<ProgressionTile snapshot={snapshot} size="compact" />,
);
expect(screen.queryByTestId("progression-chart")).not.toBeInTheDocument();
rerender(<ProgressionTile snapshot={snapshot} size="medium" />);
expect(
await screen.findByTestId("progression-chart", undefined, {
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) => {
const { container } = render(<MaxRecordsTile snapshot={dashboardFixture()} size={size} />)
expect(container.querySelectorAll('.record-list li')).toHaveLength(expected)
if (size === 'large') expect(screen.getByText('8 MAX récents affichés')).toBeInTheDocument()
})
it("omet toute durée fabriquée lorsque ended_at est absent et révèle les zones en grand", () => {
const snapshot = dashboardFixture();
const { rerender } = render(
<LastSessionTile snapshot={snapshot} size="medium" />,
);
expect(screen.getByText("Indisponible")).toBeInTheDocument();
if (!snapshot.data.last_session.available)
throw new Error("fixture invalide");
snapshot.data.last_session.duration_seconds = 3720;
rerender(<LastSessionTile snapshot={snapshot} size="large" />);
expect(screen.getByText("1 h 02 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', () => {
const { container } = render(<MuscleDistributionTile snapshot={dashboardFixture()} size="large" />)
const chest = within(container.querySelector('.muscle-list li') as HTMLElement)
expect(chest.getByText('4 séances')).toBeInTheDocument()
expect(chest.getByText(/6 occurrences · 18 séries/)).toBeInTheDocument()
expect((container.querySelector('.muscle-track span') as HTMLElement).style.width).toBe('100%')
expect(screen.getByText(/Longueur : nombre de séances/)).toBeInTheDocument()
})
it.each([
["compact", 1],
["medium", 3],
["large", 8],
] as const)("borne les MAX en taille %s à %i", (size, expected) => {
const { container } = render(
<MaxRecordsTile snapshot={dashboardFixture()} size={size} />,
);
expect(container.querySelectorAll(".record-list li")).toHaveLength(
expected,
);
if (size === "large")
expect(screen.getByText("8 MAX récents affichés")).toBeInTheDocument();
});
it('affiche la silhouette en medium/large, jamais en compact, et conserve une zone non mappée dans la liste', () => {
const snapshot = dashboardFixture()
snapshot.data.muscle_distribution.primary_zones.push({ zone_id: 'full_body', label: 'Corps entier', session_count: 1, occurrence_count: 1, set_count: 0 })
const { rerender } = render(<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("encode les barres musculaires avec les séances et conserve les valeurs textuelles", () => {
const { container } = render(
<MuscleDistributionTile snapshot={dashboardFixture()} size="large" />,
);
const chest = within(
container.querySelector(".muscle-list li") as HTMLElement,
);
expect(chest.getByText("4 séances")).toBeInTheDocument();
expect(chest.getByText(/6 occurrences · 18 séries/)).toBeInTheDocument();
expect(
(container.querySelector(".muscle-track span") as HTMLElement).style
.width,
).toBe("100%");
expect(
screen.getByText(/Longueur : nombre de séances/),
).toBeInTheDocument();
});
it('gère les états indisponibles des domaines optionnels', () => {
const snapshot = dashboardFixture()
snapshot.data.progression = { available: false, reason: 'no_comparable_performance' }
snapshot.data.last_session = { available: false, reason: 'no_observable_session' }
snapshot.data.max_records = { available: false, records: [] }
snapshot.data.muscle_distribution = { available: false, window_days: 30, primary_zones: [] }
const { rerender } = render(<ProgressionTile snapshot={snapshot} size="compact" />)
expect(screen.getByText(/Aucune performance/)).toBeInTheDocument()
rerender(<LastSessionTile snapshot={snapshot} size="compact" />); expect(screen.getByText(/Aucune séance observable/)).toBeInTheDocument()
rerender(<MaxRecordsTile snapshot={snapshot} size="compact" />); expect(screen.getByText(/Aucun MAX explicite/)).toBeInTheDocument()
rerender(<MuscleDistributionTile snapshot={snapshot} size="compact" />); expect(screen.getByText(/Aucune zone travaillée/)).toBeInTheDocument()
})
})
it("affiche la silhouette en medium/large, jamais en compact, et conserve une zone non mappée dans la liste", () => {
const snapshot = dashboardFixture();
snapshot.data.muscle_distribution.primary_zones.push({
zone_id: "full_body",
label: "Corps entier",
session_count: 1,
occurrence_count: 1,
set_count: 0,
});
const { rerender } = render(
<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 { lazy, Suspense, type ComponentType, type ReactNode } from 'react'
import type { TileSize } from './dashboardLayout'
import { count, formatDate, formatDateTime, formatDose, formatDuration, 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'
import type { DashboardSnapshot, WorkedZone } from "../api/dashboard";
import { lazy, Suspense, type ComponentType, type ReactNode } from "react";
import type { TileSize } from "./dashboardLayout";
import {
count,
formatDate,
formatDateTime,
formatDose,
formatDuration,
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 }
export interface NextSessionTileProps {
size: TileSize
preparedItems?: PreparedItemsSnapshot | null
pending?: boolean
failed?: boolean
interface TileDataProps {
snapshot: DashboardSnapshot;
size: TileSize;
analysis?: AnalysisSnapshot | null;
}
export function sortPreparedItems(items: readonly PreparedItem[]): PreparedItem[] {
export interface NextSessionTileProps {
size: TileSize;
preparedItems?: PreparedItemsSnapshot | null;
pending?: boolean;
failed?: boolean;
}
export function sortPreparedItems(
items: readonly PreparedItem[],
): PreparedItem[] {
return [...items].sort((left, right) => {
const leftDate = validDateSortValue(left.planned_for)
const rightDate = validDateSortValue(right.planned_for)
const leftDate = validDateSortValue(left.planned_for);
const rightDate = validDateSortValue(right.planned_for);
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
const leftTimestamp = validTimestampValue(left.sort_timestamp)
const rightTimestamp = validTimestampValue(right.sort_timestamp)
if (leftTimestamp !== null && rightTimestamp !== null && leftTimestamp !== rightTimestamp) {
return rightTimestamp - leftTimestamp
if (leftDate === null && rightDate !== null) return 1;
if (leftDate !== null && rightDate === null) return -1;
const leftTimestamp = validTimestampValue(left.sort_timestamp);
const rightTimestamp = validTimestampValue(right.sort_timestamp);
if (
leftTimestamp !== null &&
rightTimestamp !== null &&
leftTimestamp !== rightTimestamp
) {
return rightTimestamp - leftTimestamp;
}
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')
})
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",
);
});
}
export function NextSessionTile({
@ -46,51 +73,94 @@ export function NextSessionTile({
pending = false,
failed = false,
}: NextSessionTileProps) {
const { dateFormat } = useDatePreferences()
const { dateFormat } = useDatePreferences();
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 item = orderedItems[0]
const orderedItems = sortPreparedItems(preparedItems.items);
const item = orderedItems[0];
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 manual = item.kind === 'manual_preparation'
const proposal = item.kind === "ai_proposal";
const manual = item.kind === "manual_preparation";
const heading = proposal
? 'Proposition préparée · à valider'
: manual ? 'Préparation manuelle'
: item.state === 'active'
? 'Brouillon d’exécution · à reprendre'
: 'Brouillon d’exécution · en attente'
const title = item.title || (proposal ? 'Proposition sans titre' : 'Séance préparée')
? "Proposition préparée · à valider"
: manual
? "Préparation manuelle"
: item.state === "active"
? "Brouillon d’exécution · à reprendre"
: "Brouillon d’exécution · en attente";
const title =
item.title || (proposal ? "Proposition sans titre" : "Séance préparée");
return (
<div className="session-content">
<p className="primary-label">{heading}</p>
<strong>{title}</strong>
{size !== 'compact' && (
{size !== "compact" && (
<>
<dl className="fact-list horizontal">
<Fact label="Éléments" value={String(item.occurrence_count)} />
<Fact label="Prévue" value={item.planned_for
? <time dateTime={item.planned_for}>{formatDate(item.planned_for, dateFormat)}</time>
: 'Non planifiée'} />
<Fact
label="Prévue"
value={
item.planned_for ? (
<time dateTime={item.planned_for}>
{formatDate(item.planned_for, dateFormat)}
</time>
) : (
"Non planifiée"
)
}
/>
</dl>
{orderedItems.length > 1 && (
<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>
{orderedItems.map((prepared) => (
<li key={`${prepared.kind}:${prepared.identity}`} data-prepared-identity={prepared.identity}>
<a href={`/seances/${prepared.kind === 'ai_proposal' ? 'proposal'
: prepared.kind === 'manual_preparation' ? '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
key={`${prepared.kind}:${prepared.identity}`}
data-prepared-identity={prepared.identity}
>
<a
href={`/seances/${
prepared.kind === "ai_proposal"
? "proposal"
: prepared.kind === "manual_preparation"
? "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>
))}
</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>}
<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>
)
);
}
export function ActivityTile({ snapshot, size, analysis }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const days = snapshot.data.activity.days
const { dateFormat } = useDatePreferences();
const days = snapshot.data.activity.days;
// CONTRACT: these are direct presentation totals of Core-projected counters,
// never an activity or physiological-intensity score.
const activeDays = days.filter((day) => day.active).length
const sessions = analysis?.overview.sessions ?? days.reduce((total, day) => total + day.session_count, 0)
const sets = analysis?.overview.sets ?? days.reduce((total, day) => total + day.set_count, 0)
const shown = size === 'compact' ? days.slice(-35) : days
return <div className="activity-content">
<div className="metric-row"><Metric value={String(activeDays)} label="jours actifs" />{size !== 'compact' && <Metric value={String(sessions)} label="séances" />}{size === 'large' && <Metric value={String(sets)} label="séries" />}{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 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')}`} />)}
const activeDays = days.filter((day) => day.active).length;
const sessions =
analysis?.overview.sessions ??
days.reduce((total, day) => total + day.session_count, 0);
const sets =
analysis?.overview.sets ??
days.reduce((total, day) => total + day.set_count, 0);
const shown = size === "compact" ? days.slice(-35) : days;
return (
<div className="activity-content">
<div className="metric-row">
<Metric value={String(activeDays)} label="jours actifs" />
{size !== "compact" && (
<Metric value={String(sessions)} label="séances" />
)}
{size === "large" && <Metric value={String(sets)} label="séries" />}
{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
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>
{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>
{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) {
const { dateFormat } = useDatePreferences()
const { dateFormat } = useDatePreferences();
if (analysis?.exercise !== null && analysis?.exercise !== undefined) {
const exercise = analysis.exercise
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>
const exercise = analysis.exercise;
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">
{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>
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) {
const { dateFormat } = useDatePreferences()
const session = snapshot.data.last_session
if (!session.available) return <Unavailable text="Aucune séance observable disponible" />
return <div className="session-content">
<p className="primary-label"><time dateTime={session.started_at}>{size === 'compact'
? formatDate(session.started_at, dateFormat)
: formatDateTime(session.started_at, dateFormat)}</time></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>
const { dateFormat } = useDatePreferences();
const session = snapshot.data.last_session;
if (!session.available)
return <Unavailable text="Aucune séance observable disponible" />;
return (
<div className="session-content">
<p className="primary-label">
<time dateTime={session.started_at}>
{size === "compact"
? formatDate(session.started_at, dateFormat)
: formatDateTime(session.started_at, dateFormat)}
</time>
</p>
<dl className="fact-list horizontal">
<Fact label="Exercices" value={String(session.exercise_count)} />
<Fact label="Séries" value={String(session.set_count)} />
{size !== "compact" && (
<>
<Fact
label="Durée"
value={
session.duration_seconds === null
? "Indisponible"
: formatDuration(session.duration_seconds)
}
/>
<Fact
label="Activités continues"
value={String(session.continuous_count)}
/>
<Fact label="MAX explicites" value={String(session.max_count)} />
</>
)}
</dl>
{size === "large" && (
<div className="zone-chips" aria-label="Zones primaires travaillées">
{session.primary_zones.map((zone) => (
<span key={zone.zone_id}>{zone.label}</span>
))}
</div>
)}
</div>
);
}
export function MaxRecordsTile({ snapshot, size }: TileDataProps) {
const { dateFormat } = useDatePreferences()
const records = snapshot.data.max_records.records
if (!snapshot.data.max_records.available || records.length === 0) return <Unavailable text="Aucun MAX explicite disponible" />
const limit = size === 'compact' ? 1 : size === 'medium' ? 3 : 8
return <div className="max-content"><ol className="record-list">{records.slice(0, limit).map((record) => <li key={record.entry_id}><div><strong>{record.exercise_name}</strong><span>{formatDate(record.timestamp, 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 { dateFormat } = useDatePreferences();
const records = snapshot.data.max_records.records;
if (!snapshot.data.max_records.available || records.length === 0)
return <Unavailable text="Aucun MAX explicite disponible" />;
const limit = size === "compact" ? 1 : size === "medium" ? 3 : 8;
return (
<div className="max-content">
<ol className="record-list">
{records.slice(0, limit).map((record) => (
<li key={record.entry_id}>
<div>
<strong>{record.exercise_name}</strong>
<span>
{formatDate(record.timestamp, 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) {
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" />
const zones = snapshot.data.muscle_distribution.primary_zones;
if (!snapshot.data.muscle_distribution.available || zones.length === 0)
return <Unavailable text="Aucune zone travaillée sur 30 jours" />;
// INVARIANT: deterministic display order is separate from bar semantics;
// each bar length represents session_count alone.
const ordered = [...zones].sort((a, b) => b.session_count - a.session_count || b.occurrence_count - a.occurrence_count || a.label.localeCompare(b.label, 'fr'))
const shown = size === 'compact' ? ordered.slice(0, 3) : ordered
const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1)
return <div className="muscle-content">
{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 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>
const ordered = [...zones].sort(
(a, b) =>
b.session_count - a.session_count ||
b.occurrence_count - a.occurrence_count ||
a.label.localeCompare(b.label, "fr"),
);
const shown = size === "compact" ? ordered.slice(0, 3) : ordered;
const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1);
return (
<div className="muscle-content">
{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 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 }) {
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>
function MuscleRow({
zone,
maximum,
detailed,
full,
}: {
zone: WorkedZone;
maximum: number;
detailed: boolean;
full: boolean;
}) {
return (
<li>
<div className="muscle-label">
<strong>{zone.label}</strong>
<span>{count(zone.session_count, "séance")}</span>
</div>
<div className="muscle-track" aria-hidden="true">
<span style={{ width: `${(zone.session_count / maximum) * 100}%` }} />
</div>
{detailed && (
<p>
{count(zone.occurrence_count, "occurrence")}
{full && ` · ${count(zone.set_count, "série")}`}
</p>
)}
</li>
);
}
export function CardioTile({ size }: TileDataProps) {
return <div className="unavailable-content"><strong aria-hidden="true">—</strong>{size !== 'compact' && <p>Aucune donnée cardio disponible</p>}{size === 'large' && <small>Les mesures apparaîtront ici lorsqu’une source cardio sera enregistrée.</small>}</div>
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) {
const program = analysis?.active_program
if (program === null || program === undefined) return <Unavailable text="Aucun programme actif" />
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>
const program = analysis?.active_program;
if (program === null || program === undefined)
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) {
const summaries = analysis?.measurements.summaries ?? []
const visible = summaries.filter((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>
const summaries = analysis?.measurements.summaries ?? [];
const visible = summaries
.filter(
(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 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> }
function Metric({ value, label }: { value: string; label: string }) {
return (
<div className="metric">
<strong>{value}</strong>
<span>{label}</span>
</div>
);
}
function Fact({ label, value }: { label: string; value: 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 {
formatDate as formatPresentationDate,
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') =>
formatPresentationDate(value, format)
export const formatDateTime = (value: string, format: WebDateFormat = 'fr') =>
formatPresentationDateTime(value, format)
export const formatWeight = (value: number) => `${numberFormat.format(value)} kg`
export const formatDose = (value: number) => numberFormat.format(value)
export const formatDate = (value: string, format: WebDateFormat = "fr") =>
formatPresentationDate(value, format);
export const formatDateTime = (value: string, format: WebDateFormat = "fr") =>
formatPresentationDateTime(value, format);
export const formatWeight = (value: number) =>
`${numberFormat.format(value)} kg`;
export const formatDose = (value: number) => numberFormat.format(value);
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remaining = seconds % 60
if (hours > 0) return `${hours} h ${minutes.toString().padStart(2, '0')}`
if (minutes > 0) return `${minutes} min${remaining ? ` ${remaining} s` : ''}`
return `${remaining} s`
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remaining = seconds % 60;
if (hours > 0)
return `${hours} h ${minutes.toString().padStart(2, "0")} min${remaining ? ` ${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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AnalysisPage } from './AnalysisPage'
import { analysisFixture } from '../api/analysis.test'
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnalysisPage } from "./AnalysisPage";
import { analysisFixture } from "../api/analysis.test";
import type { AnalysisSnapshot } from "../api/analysis";
describe('AnalysisPage', () => {
describe("AnalysisPage", () => {
beforeEach(() => {
window.history.replaceState(null, '', '/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())
document.documentElement.lang = "fr";
window.history.replaceState(
null,
"",
"/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 () => {
render(<AnalysisPage />)
expect(await screen.findByRole('heading', { name: 'Analyse' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Vue d’ensemble' })).toHaveAttribute('aria-selected', 'true')
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("falls back safely and changes sections, periods and metrics", async () => {
render(<AnalysisPage />);
expect(
await screen.findByRole("heading", { name: "Analyse" }),
).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Vue d’ensemble" })).toHaveAttribute(
"aria-selected",
"true",
);
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 () => {
window.history.replaceState(null, '', `/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`)
render(<AnalysisPage />)
expect(await screen.findByRole('heading', { name: 'Presse' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Répartition' }))
expect(screen.getByText(/pourcentages physiologiques/)).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Langue'), { target: { value: 'en' } })
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByText(/not physiological percentages/)).toBeInTheDocument()
})
})
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`,
);
render(<AnalysisPage />);
expect(
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 { ANALYSIS_PERIODS, MEASUREMENT_METRICS, fetchAnalysis, type AnalysisExercisePoint, type AnalysisPeriod, type AnalysisSnapshot, type MeasurementMetric } from '../api/analysis'
import { BodyZoneFigure } from '../dashboard/BodyZoneFigure'
import { analysisMessages, type AnalysisLanguage } from '../analysis/messages'
import { useEffect, useMemo, useState } from "react";
import {
ANALYSIS_PERIODS,
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'
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',
type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements";
type ExerciseMetric =
"duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max";
interface ChartPoint {
timestamp: string;
value: number;
}
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() {
const query = new URLSearchParams(window.location.search)
const sectionValue = query.get('section')
const periodValue = query.get('period')
const metricValue = query.get('metric')
const exerciseValue = query.get('exercise_id')
const query = new URLSearchParams(window.location.search);
const sectionValue = query.get("section");
const periodValue = query.get("period");
const metricValue = query.get("metric");
const exerciseValue = query.get("exercise_id");
return {
section: sections.includes(sectionValue as AnalysisSection) ? sectionValue as AnalysisSection : 'overview',
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,
}
section: sections.includes(sectionValue as AnalysisSection)
? (sectionValue as AnalysisSection)
: "overview",
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) {
return new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : 'en-US', { maximumFractionDigits }).format(value)
const analysisLanguage = (): AnalysisLanguage =>
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) {
const hours = Math.floor(value / 3600)
const minutes = Math.floor((value % 3600) / 60)
return hours > 0 ? `${hours} h ${minutes.toString().padStart(2, '0')} min` : `${minutes} min${language === 'en' ? '' : ''}`
function chartPoints(
exercise: AnalysisExercise,
metric: ExerciseMetricDefinition,
): 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 }) {
if (points.length === 0) return null
const ordered = [...points].reverse()
const values = ordered.map((point) => point.value)
const minimum = Math.min(...values)
const maximum = Math.max(...values)
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(' ')
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>
export function AnalysisLineChart({
points,
unit,
language,
label,
displayValue,
}: {
points: ChartPoint[];
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 }) {
const t = analysisMessages[language]
if (points.length === 0) return <p className="analysis-empty">{t.noExerciseData}</p>
const latest = points[0]
return <>
<dl className="analysis-facts">
<Fact label={t.sets} value={latest.sets} language={language} />
{latest.reps !== null && <Fact label={t.reps} value={latest.reps} language={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.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} />}
{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.explicit_max_kg !== null && <Fact label={t.max} value={latest.explicit_max_kg} unit="kg" language={language} />}
{latest.load_mode === 'none' && latest.reps !== null && <Fact label={t.frequency} value={points.length} language={language} />}
</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} />
</>
function ActivityHistogram({
points,
language,
}: {
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 Fact({ label, value, unit, language }: { label: string; value: number; unit?: string; language: AnalysisLanguage }) {
return <div><dt>{label}</dt><dd>{formatNumber(value, language)}{unit ? ` ${unit}` : ''}</dd></div>
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">
{exercise.recording_mode === "sets" && (
<Fact label={t.sets} value={formatNumber(latest.sets, language)} />
)}
{latest.reps !== null && (
<Fact label={t.reps} value={formatNumber(latest.reps, language)} />
)}
{latest.external_load_kg !== null && (
<Fact
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>
{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 }: { label: string; value: string }) {
return (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
}
export function AnalysisPage() {
const initial = useMemo(safeInitialQuery, [])
const [section, setSection] = useState<AnalysisSection>(initial.section)
const [period, setPeriod] = useState<AnalysisPeriod>(initial.period)
const [metric, setMetric] = useState<MeasurementMetric>(initial.metric)
const [exerciseId, setExerciseId] = useState<string | undefined>(initial.exerciseId)
const [language, setLanguage] = useState<AnalysisLanguage>('fr')
const [snapshot, setSnapshot] = useState<AnalysisSnapshot | null>(null)
const [pending, setPending] = useState(true)
const [failed, setFailed] = useState(false)
const t = analysisMessages[language]
const initial = useMemo(safeInitialQuery, []);
const language = analysisLanguage();
const [section, setSection] = useState<AnalysisSection>(initial.section);
const [period, setPeriod] = useState<AnalysisPeriod>(initial.period);
const [metric, setMetric] = useState<MeasurementMetric>(initial.metric);
const [exerciseId, setExerciseId] = useState<string | undefined>(
initial.exerciseId,
);
const [exerciseMetric, setExerciseMetric] = useState<ExerciseMetric | null>(
null,
);
const [snapshot, setSnapshot] = useState<AnalysisSnapshot | null>(null);
const [pending, setPending] = useState(true);
const [failed, setFailed] = useState(false);
const t = analysisMessages[language];
useEffect(() => {
const controller = new AbortController()
setPending(true)
fetchAnalysis({ period, exerciseId, metric }, controller.signal).then((value) => {
setSnapshot(value); setFailed(false)
if (exerciseId === undefined && value.exercise !== null) 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])
const controller = new AbortController();
setPending(true);
fetchAnalysis({ period, exerciseId, metric }, controller.signal)
.then((value) => {
setSnapshot(value);
setFailed(false);
if (exerciseId === undefined && value.exercise !== null)
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) {
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 updateUrl(
nextSection = section,
nextPeriod = period,
nextExercise = exerciseId,
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 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 })) ?? []
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>
<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>}
{failed && snapshot === null && <p className="error-panel" role="alert">{t.failed}</p>}
{snapshot !== null && <>
{snapshot.meta.partial && <p className="analysis-note">{t.partial}</p>}
{section === 'overview' && <div className="analysis-section-grid">
<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>
<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>
</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 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 === '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>}
</>}
</section>
const selectedSummary = snapshot?.measurements.summaries.find(
(item) => item.metric === metric,
);
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,
})) ?? [];
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>}
{failed && snapshot === null && (
<p className="error-panel" role="alert">
{t.failed}
</p>
)}
{snapshot !== null && (
<>
{snapshot.meta.partial && (
<p className="analysis-note">{t.partial}</p>
)}
{section === "overview" && (
<div className="analysis-section-grid">
<article className="analysis-panel">
<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