feat(web): chart body measurement trends

This commit is contained in:
fy59 2026-09-22 18:07:12 +02:00
parent 8a334613f8
commit b4fd753ac5
10 changed files with 330 additions and 34 deletions

View file

@ -381,10 +381,12 @@ static bool add_measurements(TrainlogDatabase *database,
bool *partial) {
yyjson_mut_val *measurements = yyjson_mut_obj(document);
yyjson_mut_val *summaries = yyjson_mut_arr(document);
yyjson_mut_val *series = yyjson_mut_arr(document);
yyjson_mut_val *points = yyjson_mut_arr(document);
const MeasurementDefinition *selected = measurement_definition(query->measurement_metric);
size_t index;
if (measurements == NULL || summaries == NULL || points == NULL || selected == NULL) {
if (measurements == NULL || summaries == NULL || series == NULL || points == NULL ||
selected == NULL) {
return false;
}
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
@ -426,6 +428,64 @@ static bool add_measurements(TrainlogDatabase *database,
return false;
}
}
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
char sql[512];
sqlite3_stmt *statement = NULL;
yyjson_mut_val *metric_points = yyjson_mut_arr(document);
yyjson_mut_val *metric_series;
size_t count = 0U;
int step;
int written = snprintf(
sql,
sizeof(sql),
"SELECT observed_at,%s FROM body_observations WHERE %s IS NOT NULL "
"AND (?1 IS NULL OR unixepoch(observed_at)>=?1) AND unixepoch(observed_at)<=?2 "
"ORDER BY unixepoch(observed_at),observation_id LIMIT ?3",
MEASUREMENTS[index].column,
MEASUREMENTS[index].column);
if (metric_points == NULL || written < 0 || (size_t)written >= sizeof(sql) ||
!prepare(database, sql, &statement) || !bind_interval(statement, query) ||
sqlite3_bind_int64(statement, 3, TRAINLOG_WEB_ANALYSIS_POINTS_MAX + 1U) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *point;
if (count == TRAINLOG_WEB_ANALYSIS_POINTS_MAX) {
*partial = true;
continue;
}
point = yyjson_mut_obj(document);
if (point == NULL ||
!yyjson_mut_obj_add_strcpy(document,
point,
"timestamp",
(const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_obj_add_real(
document, point, "value", sqlite3_column_double(statement, 1)) ||
!yyjson_mut_arr_add_val(metric_points, point)) {
(void)sqlite3_finalize(statement);
return false;
}
++count;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return false;
}
if (count < 2U) {
continue;
}
metric_series = yyjson_mut_obj(document);
if (metric_series == NULL ||
!yyjson_mut_obj_add_strcpy(document, metric_series, "metric", MEASUREMENTS[index].id) ||
!yyjson_mut_obj_add_strcpy(document, metric_series, "unit", MEASUREMENTS[index].unit) ||
!yyjson_mut_obj_add_val(document, metric_series, "points", metric_points) ||
!yyjson_mut_arr_add_val(series, metric_series)) {
return false;
}
}
{
char sql[512];
sqlite3_stmt *statement = NULL;
@ -472,6 +532,7 @@ static bool add_measurements(TrainlogDatabase *database,
}
}
return yyjson_mut_obj_add_val(document, measurements, "summaries", summaries) &&
yyjson_mut_obj_add_val(document, measurements, "series", series) &&
yyjson_mut_obj_add_strcpy(document, measurements, "selected_metric", selected->id) &&
yyjson_mut_obj_add_strcpy(document, measurements, "unit", selected->unit) &&
yyjson_mut_obj_add_val(document, measurements, "points", points) &&

View file

@ -231,6 +231,16 @@ static bool test_factual_models(void) {
}
measurements = yyjson_obj_get(root, "measurements");
CHECK(yyjson_arr_size(yyjson_obj_get(measurements, "points")) == 2U);
{
yyjson_val *series = yyjson_obj_get(measurements, "series");
yyjson_val *first_series = yyjson_arr_get(series, 0U);
yyjson_val *second_series = yyjson_arr_get(series, 1U);
CHECK(yyjson_arr_size(series) == 2U);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(first_series, "metric")), "weight") == 0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(second_series, "metric")), "waist") == 0);
CHECK(yyjson_arr_size(yyjson_obj_get(first_series, "points")) == 2U);
CHECK(yyjson_arr_size(yyjson_obj_get(second_series, "points")) == 2U);
}
summaries = yyjson_obj_get(measurements, "summaries");
waist = yyjson_arr_get(summaries, 4U);
CHECK(yyjson_get_int(yyjson_obj_get(waist, "count")) == 2);

View file

@ -14,6 +14,7 @@ export const analysisFixture: AnalysisSnapshot = {
body_zones: [{ zone_id: 'thighs', label: 'Cuisses', exposures: 1, associated_sets: 2 }],
measurements: {
selected_metric: 'waist', unit: 'cm', points: [{ timestamp: '2026-09-20T08:00:00Z', value: 88 }],
series: [],
summaries: [
{ metric: 'weight', unit: 'kg', count: 0, first: null, last: null, delta: null },
{ metric: 'neck', unit: 'cm', count: 0, first: null, last: null, delta: null },

View file

@ -21,6 +21,11 @@ export interface AnalysisExercise extends AnalysisExerciseOption { points: Analy
export interface AnalysisZone { zone_id: string; label: string; exposures: number; associated_sets: number }
export interface MeasurementSummary { metric: MeasurementMetric; unit: 'kg' | 'cm'; count: number; first: number | null; last: number | null; delta: number | null }
export interface MeasurementPoint { timestamp: string; value: number }
export interface MeasurementSeries {
metric: MeasurementMetric
unit: 'kg' | 'cm'
points: MeasurementPoint[]
}
export interface ActiveProgram {
program_id: string
title: string
@ -39,7 +44,13 @@ export interface AnalysisSnapshot {
exercises: AnalysisExerciseOption[]
exercise: AnalysisExercise | null
body_zones: AnalysisZone[]
measurements: { summaries: MeasurementSummary[]; selected_metric: MeasurementMetric; unit: 'kg' | 'cm'; points: MeasurementPoint[] }
measurements: {
summaries: MeasurementSummary[]
series: MeasurementSeries[]
selected_metric: MeasurementMetric
unit: 'kg' | 'cm'
points: MeasurementPoint[]
}
active_program: ActiveProgram | null
meta: { partial: boolean; reference_unix_second: number }
}
@ -72,8 +83,13 @@ export function parseAnalysis(value: unknown): AnalysisSnapshot {
}
if (!value.body_zones.every((item) => record(item) && typeof item.zone_id === 'string' && typeof item.label === 'string' && count(item.exposures) && count(item.associated_sets))) throw new TypeError('analysis_zones_invalid')
const summaries = value.measurements.summaries
const series = value.measurements.series
const points = value.measurements.points
if (!Array.isArray(summaries) || summaries.length !== MEASUREMENT_METRICS.length || !summaries.every((item) => record(item) && metric(item.metric) && (item.unit === 'kg' || item.unit === 'cm') && count(item.count) && nullableFinite(item.first) && nullableFinite(item.last) && nullableFinite(item.delta)) ||
!Array.isArray(series) || series.length > MEASUREMENT_METRICS.length ||
!series.every((item) => record(item) && metric(item.metric) && (item.unit === 'kg' || item.unit === 'cm') &&
Array.isArray(item.points) && item.points.length >= 2 && item.points.length <= 90 &&
item.points.every((point) => record(point) && typeof point.timestamp === 'string' && finite(point.value))) ||
!metric(value.measurements.selected_metric) || (value.measurements.unit !== 'kg' && value.measurements.unit !== 'cm') || !Array.isArray(points) || points.length > 90 || !points.every((item) => record(item) && typeof item.timestamp === 'string' && finite(item.value))) throw new TypeError('analysis_measurements_invalid')
if (value.active_program !== null && (!record(value.active_program) || typeof value.active_program.program_id !== 'string' || typeof value.active_program.title !== 'string' || !count(value.active_program.total_sessions) || !count(value.active_program.completed_sessions) || !(value.active_program.next_session_title === null || typeof value.active_program.next_session_title === 'string') || !(value.active_program.next_session_id === null || typeof value.active_program.next_session_id === 'string') || !(value.active_program.next_session_planned_for === null || typeof value.active_program.next_session_planned_for === 'string'))) throw new TypeError('analysis_program_invalid')
if (typeof value.meta.partial !== 'boolean' || !count(value.meta.reference_unix_second)) throw new TypeError('analysis_meta_invalid')

View file

@ -15,6 +15,14 @@ import { useRoute } from './useRoute'
import { DatePreferencesProvider } from '../presentation/DatePreferences'
import { fetchAnalysis, type AnalysisSnapshot } from '../api/analysis'
async function fetchDashboardAnalysis(signal?: AbortSignal): Promise<AnalysisSnapshot> {
const [windowed, allMeasurements] = await Promise.all([
fetchAnalysis({}, signal),
fetchAnalysis({ period: 'all' }, signal),
])
return { ...windowed, measurements: allMeasurements.measurements }
}
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'analysis' | 'programs' | 'exercises'>, [string, string, string]> = {
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
}
@ -49,7 +57,7 @@ function AppContent() {
})
}, [])
const reloadAnalysis = useCallback(() => {
fetchAnalysis({}).then(setAnalysis).catch(() => undefined)
fetchDashboardAnalysis().then(setAnalysis).catch(() => undefined)
}, [])
useEffect(() => {
@ -69,7 +77,7 @@ function AppContent() {
}).catch(() => {
setPreparedItemsFailed(true)
}).finally(() => setPreparedItemsPending(false))
fetchAnalysis({}, controller.signal).then(setAnalysis).catch(() => setAnalysis(null))
fetchDashboardAnalysis(controller.signal).then(setAnalysis).catch(() => setAnalysis(null))
return () => controller.abort()
}, [])

View file

@ -0,0 +1,175 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import type { MeasurementMetric, MeasurementSeries } from '../api/analysis'
import { trainlogChartTheme } from './trainlogChartTheme'
import { useDatePreferences } from '../presentation/DatePreferences'
import { formatDateTime } from '../dashboard/dashboardFormat'
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, SVGRenderer])
const labels: Record<MeasurementMetric, string> = {
weight: 'Poids',
neck: 'Cou',
shoulders: 'Épaules',
chest: 'Poitrine',
waist: 'Taille',
hips: 'Hanches',
left_arm: 'Bras G',
right_arm: 'Bras D',
left_forearm: 'Avant-bras G',
right_forearm: 'Avant-bras D',
left_thigh: 'Cuisse G',
right_thigh: 'Cuisse D',
left_calf: 'Mollet G',
right_calf: 'Mollet D',
}
interface MeasurementsEvolutionChartProps {
series: MeasurementSeries[]
}
interface EChartsTooltipParam {
seriesName?: string
value?: [number, number]
marker?: string
}
export function MeasurementsEvolutionChart({ series }: MeasurementsEvolutionChartProps) {
const host = useRef<HTMLDivElement>(null)
const { dateFormat } = useDatePreferences()
useEffect(() => {
if (host.current === null || series.length === 0) return
const element = host.current
const theme = trainlogChartTheme(element)
const chart = echarts.init(element, undefined, { renderer: 'svg' })
const hasCentimeters = series.some((item) => item.unit === 'cm')
const hasKilograms = series.some((item) => item.unit === 'kg')
const colors = [
theme.lavender,
theme.mauve,
theme.blue,
theme.sapphire,
theme.green,
theme.yellow,
theme.peach,
theme.red,
theme.overlay1,
theme.surface2,
]
const yAxis = [
...(hasCentimeters ? [{
type: 'value' as const,
name: 'cm',
scale: true,
position: 'left' as const,
nameTextStyle: { color: theme.subtext, fontSize: 11 },
axisLabel: { color: theme.subtext, fontSize: 11, formatter: '{value} cm' },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: theme.surface0 } },
}] : []),
...(hasKilograms ? [{
type: 'value' as const,
name: 'kg',
scale: true,
position: hasCentimeters ? 'right' as const : 'left' as const,
nameTextStyle: { color: theme.subtext, fontSize: 11 },
axisLabel: { color: theme.subtext, fontSize: 11, formatter: '{value} kg' },
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: !hasCentimeters, lineStyle: { color: theme.surface0 } },
}] : []),
]
chart.setOption({
animation: !matchMedia('(prefers-reduced-motion: reduce)').matches,
color: colors,
textStyle: { color: theme.text, fontFamily: theme.fontSans, fontSize: theme.fontSize },
grid: {
left: hasCentimeters ? 56 : 58,
right: hasCentimeters && hasKilograms ? 58 : 16,
top: 14,
bottom: series.length > 4 ? 66 : 48,
},
legend: {
type: 'scroll',
bottom: 0,
left: 0,
right: 0,
textStyle: { color: theme.subtext, fontFamily: theme.fontSans, fontSize: 11 },
pageTextStyle: { color: theme.subtext },
pageIconColor: theme.lavender,
pageIconInactiveColor: theme.surface2,
},
tooltip: {
trigger: 'axis',
renderMode: 'richText',
confine: true,
backgroundColor: theme.mantle,
borderColor: theme.surface2,
textStyle: { color: theme.text, fontFamily: theme.fontSans, fontSize: 12, lineHeight: 18 },
formatter: (raw: unknown) => {
const params = (Array.isArray(raw) ? raw : [raw]) as EChartsTooltipParam[]
const firstValue = params.find((item) => Array.isArray(item.value))?.value
const timestamp = firstValue?.[0]
const heading = typeof timestamp === 'number'
? formatDateTime(new Date(timestamp).toISOString(), dateFormat)
: ''
const rows = params.flatMap((item) => {
const value = item.value
if (!Array.isArray(value) || typeof value[1] !== 'number') return []
const source = series.find((candidate) => labels[candidate.metric] === item.seriesName)
const unit = source?.unit ?? ''
return [`${item.marker ?? ''}${item.seriesName ?? ''} : ${value[1].toLocaleString('fr-FR', { maximumFractionDigits: 2 })} ${unit}`]
})
return [heading, ...rows].filter(Boolean).join('\n')
},
},
xAxis: {
type: 'time',
boundaryGap: false,
axisLine: { lineStyle: { color: theme.surface2 } },
axisTick: { show: false },
axisLabel: { color: theme.subtext, fontSize: 11, hideOverlap: true },
splitLine: { show: false },
},
yAxis,
series: series.map((item, index) => ({
name: labels[item.metric],
type: 'line',
yAxisIndex: item.unit === 'kg' && hasCentimeters ? 1 : 0,
data: item.points.map((point) => [new Date(point.timestamp).valueOf(), point.value]),
smooth: false,
showSymbol: true,
symbolSize: 6,
connectNulls: false,
lineStyle: { width: 2 },
itemStyle: { color: colors[index % colors.length] },
emphasis: { focus: 'series', scale: 1.15 },
})),
})
const observer = new ResizeObserver(() => chart.resize())
observer.observe(element)
return () => {
observer.disconnect()
chart.dispose()
}
}, [dateFormat, series])
const pointCount = series.reduce((total, item) => total + item.points.length, 0)
return (
<div
className="measurements-evolution-chart"
ref={host}
data-testid="measurements-evolution-chart"
role="img"
aria-label={`Évolution de ${series.length} mensuration${series.length > 1 ? 's' : ''} disposant d’au moins deux relevés, ${pointCount} mesures réelles au total.`}
/>
)
}

View file

@ -235,7 +235,7 @@ function tileAvailable(snapshot: DashboardSnapshot, id: TileId, analysis: Analys
if (id === 'activity') return snapshot.data.activity.available
if (id === 'progression') return snapshot.data.progression.available
if (id === 'last-session') return snapshot.data.last_session.available
if (id === 'max-records') return analysis?.measurements.summaries.some((item) => item.last !== null) === true
if (id === 'max-records') return (analysis?.measurements.series.length ?? 0) > 0
if (id === 'muscle-distribution') return snapshot.data.muscle_distribution.available
return analysis?.active_program !== null && analysis?.active_program !== undefined
}

View file

@ -25,6 +25,12 @@ const ProgressionChart = lazy(() =>
})),
);
const MeasurementsEvolutionChart = lazy(() =>
import("../charts/MeasurementsEvolutionChart").then((module) => ({
default: module.MeasurementsEvolutionChart,
})),
);
interface TileDataProps {
snapshot: DashboardSnapshot;
size: TileSize;
@ -617,38 +623,27 @@ export function ProgramTile({ size, analysis }: TileDataProps) {
}
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" />;
const series = analysis?.measurements.series ?? [];
if (series.length === 0) {
return (
<Unavailable
text="Deux relevés minimum sont nécessaires pour afficher une évolution"
/>
);
}
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>
<div className="measurement-evolution">
<Suspense fallback={<div className="chart-loading">Chargement du graphique…</div>}>
<MeasurementsEvolutionChart series={series} />
</Suspense>
{size !== "compact" && (
<small className="measurement-evolution-note">
Seules les mensurations disposant d’au moins deux relevés sont affichées.
</small>
)}
<a
className="tile-context-link"
href="/analyse?section=measurements&metric=waist&period=30d"
href="/analyse?section=measurements&period=all"
>
Voir dans Analyse
</a>

View file

@ -161,6 +161,14 @@ describe("AnalysisPage", () => {
selected_metric: "weight",
unit: "kg",
summaries,
series: [{
metric: "weight",
unit: "kg",
points: [
{ timestamp: "2026-08-20T08:00:00Z", value: 83.7 },
{ timestamp: "2026-09-20T08:00:00Z", value: 85.1 },
],
}],
points: [
{ timestamp: "2026-08-20T08:00:00Z", value: 83.7 },
{ timestamp: "2026-09-20T08:00:00Z", value: 85.1 },

View file

@ -1056,6 +1056,28 @@ h2 {
color: var(--lavender);
font-weight: 500;
}
.measurement-evolution {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.measurements-evolution-chart {
width: 100%;
flex: 1 1 auto;
min-height: 8rem;
}
.measurement-evolution-note {
color: var(--subtext-0);
font-size: var(--font-size-xs);
}
.tile-large .measurements-evolution-chart {
min-height: 12rem;
}
.tile-compact .measurements-evolution-chart {
min-height: 6rem;
}
.program-progress p {
margin: 0.25rem 0;
color: var(--subtext-0);