refactor(web): refine analysis visual presentation
This commit is contained in:
parent
c31c394e4f
commit
6948e0bbd5
9 changed files with 4367 additions and 773 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
15
web/src/dashboard/dashboardFormat.test.ts
Normal file
15
web/src/dashboard/dashboardFormat.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue