trainlog/web/src/routes/AnalysisPage.tsx

905 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
type AnalysisSection =
| "overview"
| "exercise"
| "body-zones"
| "measurements"
| "sleep";
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",
"sleep",
];
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");
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,
};
}
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 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,
},
];
});
}
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 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 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 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]);
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");
}
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
: id === "measurements"
? t.measurements
: t.sleep}
</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 === "sleep" && (
<SectionErrorBoundary
fallbackTitle={
language === "fr"
? "Impossible d’afficher cette section."
: "Unable to display this section."
}
retryLabel={language === "fr" ? "Réessayer" : "Try again"}
>
<SleepDiaryWorkspace period={period} language={language} />
</SectionErrorBoundary>
)}
</>
)}
</section>
);
}