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>; const zoneLabelsEn: Readonly> = { 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 (
{label} {unit}
{formatNumber(maximum, language)} {formatNumber(minimum, language)} {ordered.length > 1 && ( )} {ordered.map((point, index) => ( {shortDate(point.timestamp, language)} ·{" "} {formatValue(unit === "min" ? point.value * 60 : point.value)} {(index === 0 || index === ordered.length - 1) && ( {shortDate(point.timestamp, language)} )} ))}
{label} · {unit} · {points.length}{" "} {language === "fr" ? "points réels, sans interpolation" : "real points, no interpolation"}
); } 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.exercises), 1); return (
{language === "fr" ? "Exercices réalisés par date" : "Exercises completed by date"} {language === "fr" ? "exercices" : "exercises"}
{ordered.map((point, index) => (
{point.exercises} {(ordered.length <= 10 || index === 0 || index === ordered.length - 1) && ( )}
))}
); } 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

{t.noExerciseData}

; const latest = exercise.points[0]; const metrics = exerciseMetrics(exercise, language); const activeMetric = metrics.find((metric) => metric.id === selectedMetric) ?? metrics[0]; return ( <>
{exercise.recording_mode === "sets" && ( )} {latest.reps !== null && ( )} {latest.external_load_kg !== null && ( )} {latest.external_volume_kg !== null && ( )} {(latest.set_duration_seconds ?? latest.continuous_duration_seconds) !== null && ( )} {latest.distance_km !== null && ( )} {latest.speed_kmh !== null && ( )} {latest.explicit_max_kg !== null && ( )} {latest.load_mode === "none" && latest.reps !== null && ( )}
{activeMetric && ( <> {detailed && metrics.length > 1 && (
{metrics.map((item) => ( ))}
)} )} ); } function Fact({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } export function AnalysisPage() { const initial = useMemo(safeInitialQuery, []); const language = analysisLanguage(); const [section, setSection] = useState(initial.section); const [period, setPeriod] = useState(initial.period); const [metric, setMetric] = useState(initial.metric); const [exerciseId, setExerciseId] = useState( initial.exerciseId, ); const [exerciseMetric, setExerciseMetric] = useState( null, ); const [selectedGroup, setSelectedGroup] = useState(null); const [snapshot, setSnapshot] = useState(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) ?? []), ); const distinctExercises = snapshot?.overview.distinct_exercises ?? 0; const activeGroup = snapshot?.exercise_groups.find((group) => group.zone_id === selectedGroup); return (

{t.eyebrow}

{t.title}

{sections.map((id) => ( ))}
{pending && snapshot === null &&

{t.loading}

} {failed && snapshot === null && (

{t.failed}

)} {snapshot !== null && ( <> {snapshot.meta.partial && (

{t.partial}

)} {section === "overview" && (

{t.overview}

{snapshot.overview.duration_seconds !== null && ( )}
{(() => { const occurrences = snapshot.exercise_groups.flatMap((group) => group.exercises) .reduce((sum, exercise) => sum + exercise.occurrences, 0); const measured = snapshot.exercise_groups.flatMap((group) => group.exercises) .reduce((sum, exercise) => sum + exercise.measured_occurrences, 0); if (occurrences === 0 || measured === occurrences) return null; const coverage = measured === 0 ? (language === "fr" ? "Temps non mesuré · durée indisponible" : "Time not measured · duration unavailable") : (language === "fr" ? "Couverture temporelle partielle" : "Partial time coverage"); return

{coverage}

; })()} {snapshot.overview.sessions === 0 && (

{t.noData}

)}

{t.activeProgram}

{snapshot.active_program === null ? (

{t.noProgram}

) : (

{snapshot.active_program.title}

{snapshot.active_program.completed_sessions} {" "} {language === "fr" ? `sur ${snapshot.active_program.total_sessions} séances` : `of ${snapshot.active_program.total_sessions} sessions`}
{snapshot.active_program.next_session_title && (
{t.nextSession} {snapshot.active_program.next_session_title}
)} Programmes
)}

{t.distribution}

{zoneFigure.length === 0 ? (

{t.noData}

) : ( )}

{t.exercises}

{distinctExercises === 0 ? (

{t.noExercise}

) : ( <>

{distinctExercises}

{language === "fr" ? "exercices distincts sur la période" : "distinct exercises in this period"}

)}
)} {section === "exercise" && (
{selectedGroup === null ? <>

{language === "fr" ? "Groupes musculaires" : "Muscle groups"}

{language === "fr" ? "Temps provenant uniquement des timestamps réels début/fin des exercices." : "Time comes only from real exercise start/end timestamps."}

{snapshot.exercise_groups.map((group) => { const seconds = group.exercises.reduce((sum, item) => sum + item.measured_seconds, 0) const measured = group.exercises.reduce((sum, item) => sum + item.measured_occurrences, 0) const maximum = Math.max(1, ...snapshot.exercise_groups.map((candidate) => candidate.exercises.reduce((sum, item) => sum + item.measured_seconds, 0))) return })}
: activeGroup !== undefined && exerciseId !== undefined && snapshot.exercise?.exercise_id === exerciseId ? <>

{snapshot.exercise.name}

: activeGroup !== undefined ? <>

{language === "en" ? (zoneLabelsEn[activeGroup.zone_id] ?? activeGroup.label) : activeGroup.label}

{activeGroup.exercises.map((exercise) => )}
: null} {selectedGroup === null && snapshot.exercise === null ? (

{t.noExercise}

) : selectedGroup === null && snapshot.exercise !== null ? ( <>

{snapshot.exercise.name}

) : null}
)} {section === "body-zones" && (

{t.exposures}

{t.zonesHelp}

{zoneFigure.length === 0 ? (

{t.noData}

) : (
    {snapshot.body_zones.map((zone) => (
  • {language === "en" ? (zoneLabelsEn[zone.zone_id] ?? zone.zone_id) : zone.label} {zone.exposures} {t.exposures.toLowerCase()} ·{" "} {zone.associated_sets} {t.sets.toLowerCase()}
  • ))}
)}
)} {section === "measurements" && (

{language === "fr" ? "Vue globale" : "Overview"}

{snapshot.measurements.summaries.filter((summary) => summary.count > 0).map((summary) => { const relative = summary.first !== null && summary.last !== null && summary.first !== 0 ? ((summary.last - summary.first) / Math.abs(summary.first)) * 100 : null return })}

{metricLabels[language][metric]}

{selectedSummary === undefined || selectedSummary.count === 0 ? (

{t.noData}

) : ( <>
{t.delta}
{selectedSummary.delta === null ? "—" : `${selectedSummary.delta > 0 ? "+" : ""}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}
{selectedSummary.count === 1 && (

{t.onePoint}

)} )}
)} {section === "sleep" && ( )} )}
); }