diff --git a/tui/src/web_analysis.c b/tui/src/web_analysis.c index b1d5252..ac0f814 100644 --- a/tui/src/web_analysis.c +++ b/tui/src/web_analysis.c @@ -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) && diff --git a/tui/tests/test_web_analysis.c b/tui/tests/test_web_analysis.c index 60e84d9..eb3b12f 100644 --- a/tui/tests/test_web_analysis.c +++ b/tui/tests/test_web_analysis.c @@ -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); diff --git a/web/src/api/analysis.test.ts b/web/src/api/analysis.test.ts index 6386e3d..293f39d 100644 --- a/web/src/api/analysis.test.ts +++ b/web/src/api/analysis.test.ts @@ -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 }, diff --git a/web/src/api/analysis.ts b/web/src/api/analysis.ts index 14b3c66..5a446bb 100644 --- a/web/src/api/analysis.ts +++ b/web/src/api/analysis.ts @@ -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') diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index eed8362..148c178 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -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 { + const [windowed, allMeasurements] = await Promise.all([ + fetchAnalysis({}, signal), + fetchAnalysis({ period: 'all' }, signal), + ]) + return { ...windowed, measurements: allMeasurements.measurements } +} + const placeholderContent: Record, [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() }, []) diff --git a/web/src/charts/MeasurementsEvolutionChart.tsx b/web/src/charts/MeasurementsEvolutionChart.tsx new file mode 100644 index 0000000..7e49308 --- /dev/null +++ b/web/src/charts/MeasurementsEvolutionChart.tsx @@ -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 = { + 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(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 ( +
1 ? 's' : ''} disposant d’au moins deux relevés, ${pointCount} mesures réelles au total.`} + /> + ) +} diff --git a/web/src/dashboard/DashboardGrid.tsx b/web/src/dashboard/DashboardGrid.tsx index 1ea173d..747b233 100644 --- a/web/src/dashboard/DashboardGrid.tsx +++ b/web/src/dashboard/DashboardGrid.tsx @@ -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 } diff --git a/web/src/dashboard/DashboardTiles.tsx b/web/src/dashboard/DashboardTiles.tsx index 2077093..97577cd 100644 --- a/web/src/dashboard/DashboardTiles.tsx +++ b/web/src/dashboard/DashboardTiles.tsx @@ -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 ; + const series = analysis?.measurements.series ?? []; + if (series.length === 0) { + return ( + + ); + } return ( -
-
- {visible.map((summary) => ( -
-
{summary.metric === "weight" ? "Poids" : "Tour de taille"}
-
- {formatWeight(summary.last ?? 0).replace("kg", summary.unit)} - {summary.delta !== null && size !== "compact" && ( - - {summary.delta > 0 ? "+" : ""} - {summary.delta.toLocaleString("fr-FR", { - maximumFractionDigits: 2, - })}{" "} - {summary.unit} - - )} -
-
- ))} -
+
+ Chargement du graphique…
}> + + + {size !== "compact" && ( + + Seules les mensurations disposant d’au moins deux relevés sont affichées. + + )} Voir dans Analyse diff --git a/web/src/routes/AnalysisPage.test.tsx b/web/src/routes/AnalysisPage.test.tsx index c20be14..b8b196d 100644 --- a/web/src/routes/AnalysisPage.test.tsx +++ b/web/src/routes/AnalysisPage.test.tsx @@ -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 }, diff --git a/web/src/theme/app.css b/web/src/theme/app.css index a01ff28..6cf62f1 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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);