feat(web): chart body measurement trends
This commit is contained in:
parent
8a334613f8
commit
b4fd753ac5
10 changed files with 330 additions and 34 deletions
|
|
@ -381,10 +381,12 @@ static bool add_measurements(TrainlogDatabase *database,
|
||||||
bool *partial) {
|
bool *partial) {
|
||||||
yyjson_mut_val *measurements = yyjson_mut_obj(document);
|
yyjson_mut_val *measurements = yyjson_mut_obj(document);
|
||||||
yyjson_mut_val *summaries = yyjson_mut_arr(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);
|
yyjson_mut_val *points = yyjson_mut_arr(document);
|
||||||
const MeasurementDefinition *selected = measurement_definition(query->measurement_metric);
|
const MeasurementDefinition *selected = measurement_definition(query->measurement_metric);
|
||||||
size_t index;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
|
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
|
||||||
|
|
@ -426,6 +428,64 @@ static bool add_measurements(TrainlogDatabase *database,
|
||||||
return false;
|
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];
|
char sql[512];
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
|
|
@ -472,6 +532,7 @@ static bool add_measurements(TrainlogDatabase *database,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return yyjson_mut_obj_add_val(document, measurements, "summaries", summaries) &&
|
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, "selected_metric", selected->id) &&
|
||||||
yyjson_mut_obj_add_strcpy(document, measurements, "unit", selected->unit) &&
|
yyjson_mut_obj_add_strcpy(document, measurements, "unit", selected->unit) &&
|
||||||
yyjson_mut_obj_add_val(document, measurements, "points", points) &&
|
yyjson_mut_obj_add_val(document, measurements, "points", points) &&
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,16 @@ static bool test_factual_models(void) {
|
||||||
}
|
}
|
||||||
measurements = yyjson_obj_get(root, "measurements");
|
measurements = yyjson_obj_get(root, "measurements");
|
||||||
CHECK(yyjson_arr_size(yyjson_obj_get(measurements, "points")) == 2U);
|
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");
|
summaries = yyjson_obj_get(measurements, "summaries");
|
||||||
waist = yyjson_arr_get(summaries, 4U);
|
waist = yyjson_arr_get(summaries, 4U);
|
||||||
CHECK(yyjson_get_int(yyjson_obj_get(waist, "count")) == 2);
|
CHECK(yyjson_get_int(yyjson_obj_get(waist, "count")) == 2);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export const analysisFixture: AnalysisSnapshot = {
|
||||||
body_zones: [{ zone_id: 'thighs', label: 'Cuisses', exposures: 1, associated_sets: 2 }],
|
body_zones: [{ zone_id: 'thighs', label: 'Cuisses', exposures: 1, associated_sets: 2 }],
|
||||||
measurements: {
|
measurements: {
|
||||||
selected_metric: 'waist', unit: 'cm', points: [{ timestamp: '2026-09-20T08:00:00Z', value: 88 }],
|
selected_metric: 'waist', unit: 'cm', points: [{ timestamp: '2026-09-20T08:00:00Z', value: 88 }],
|
||||||
|
series: [],
|
||||||
summaries: [
|
summaries: [
|
||||||
{ metric: 'weight', unit: 'kg', count: 0, first: null, last: null, delta: null },
|
{ metric: 'weight', unit: 'kg', count: 0, first: null, last: null, delta: null },
|
||||||
{ metric: 'neck', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
{ metric: 'neck', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 MeasurementPoint { timestamp: string; value: number }
|
||||||
|
export interface MeasurementSeries {
|
||||||
|
metric: MeasurementMetric
|
||||||
|
unit: 'kg' | 'cm'
|
||||||
|
points: MeasurementPoint[]
|
||||||
|
}
|
||||||
export interface ActiveProgram {
|
export interface ActiveProgram {
|
||||||
program_id: string
|
program_id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -39,7 +44,13 @@ export interface AnalysisSnapshot {
|
||||||
exercises: AnalysisExerciseOption[]
|
exercises: AnalysisExerciseOption[]
|
||||||
exercise: AnalysisExercise | null
|
exercise: AnalysisExercise | null
|
||||||
body_zones: AnalysisZone[]
|
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
|
active_program: ActiveProgram | null
|
||||||
meta: { partial: boolean; reference_unix_second: number }
|
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')
|
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 summaries = value.measurements.summaries
|
||||||
|
const series = value.measurements.series
|
||||||
const points = value.measurements.points
|
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)) ||
|
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')
|
!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 (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')
|
if (typeof value.meta.partial !== 'boolean' || !count(value.meta.reference_unix_second)) throw new TypeError('analysis_meta_invalid')
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ import { useRoute } from './useRoute'
|
||||||
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
||||||
import { fetchAnalysis, type AnalysisSnapshot } from '../api/analysis'
|
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]> = {
|
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é.'],
|
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(() => {
|
const reloadAnalysis = useCallback(() => {
|
||||||
fetchAnalysis({}).then(setAnalysis).catch(() => undefined)
|
fetchDashboardAnalysis().then(setAnalysis).catch(() => undefined)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -69,7 +77,7 @@ function AppContent() {
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setPreparedItemsFailed(true)
|
setPreparedItemsFailed(true)
|
||||||
}).finally(() => setPreparedItemsPending(false))
|
}).finally(() => setPreparedItemsPending(false))
|
||||||
fetchAnalysis({}, controller.signal).then(setAnalysis).catch(() => setAnalysis(null))
|
fetchDashboardAnalysis(controller.signal).then(setAnalysis).catch(() => setAnalysis(null))
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
|
||||||
175
web/src/charts/MeasurementsEvolutionChart.tsx
Normal file
175
web/src/charts/MeasurementsEvolutionChart.tsx
Normal 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.`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -235,7 +235,7 @@ function tileAvailable(snapshot: DashboardSnapshot, id: TileId, analysis: Analys
|
||||||
if (id === 'activity') return snapshot.data.activity.available
|
if (id === 'activity') return snapshot.data.activity.available
|
||||||
if (id === 'progression') return snapshot.data.progression.available
|
if (id === 'progression') return snapshot.data.progression.available
|
||||||
if (id === 'last-session') return snapshot.data.last_session.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
|
if (id === 'muscle-distribution') return snapshot.data.muscle_distribution.available
|
||||||
return analysis?.active_program !== null && analysis?.active_program !== undefined
|
return analysis?.active_program !== null && analysis?.active_program !== undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ const ProgressionChart = lazy(() =>
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const MeasurementsEvolutionChart = lazy(() =>
|
||||||
|
import("../charts/MeasurementsEvolutionChart").then((module) => ({
|
||||||
|
default: module.MeasurementsEvolutionChart,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
interface TileDataProps {
|
interface TileDataProps {
|
||||||
snapshot: DashboardSnapshot;
|
snapshot: DashboardSnapshot;
|
||||||
size: TileSize;
|
size: TileSize;
|
||||||
|
|
@ -617,38 +623,27 @@ export function ProgramTile({ size, analysis }: TileDataProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MeasurementsTile({ size, analysis }: TileDataProps) {
|
export function MeasurementsTile({ size, analysis }: TileDataProps) {
|
||||||
const summaries = analysis?.measurements.summaries ?? [];
|
const series = analysis?.measurements.series ?? [];
|
||||||
const visible = summaries
|
if (series.length === 0) {
|
||||||
.filter(
|
return (
|
||||||
(summary) => summary.metric === "weight" || summary.metric === "waist",
|
<Unavailable
|
||||||
)
|
text="Deux relevés minimum sont nécessaires pour afficher une évolution"
|
||||||
.filter((summary) => summary.last !== null);
|
/>
|
||||||
if (visible.length === 0)
|
);
|
||||||
return <Unavailable text="Aucune mensuration disponible" />;
|
}
|
||||||
return (
|
return (
|
||||||
<div className="measurement-summary">
|
<div className="measurement-evolution">
|
||||||
<dl className="fact-list horizontal">
|
<Suspense fallback={<div className="chart-loading">Chargement du graphique…</div>}>
|
||||||
{visible.map((summary) => (
|
<MeasurementsEvolutionChart series={series} />
|
||||||
<div key={summary.metric}>
|
</Suspense>
|
||||||
<dt>{summary.metric === "weight" ? "Poids" : "Tour de taille"}</dt>
|
{size !== "compact" && (
|
||||||
<dd>
|
<small className="measurement-evolution-note">
|
||||||
{formatWeight(summary.last ?? 0).replace("kg", summary.unit)}
|
Seules les mensurations disposant d’au moins deux relevés sont affichées.
|
||||||
{summary.delta !== null && size !== "compact" && (
|
</small>
|
||||||
<small>
|
)}
|
||||||
{summary.delta > 0 ? "+" : ""}
|
|
||||||
{summary.delta.toLocaleString("fr-FR", {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}{" "}
|
|
||||||
{summary.unit}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<a
|
<a
|
||||||
className="tile-context-link"
|
className="tile-context-link"
|
||||||
href="/analyse?section=measurements&metric=waist&period=30d"
|
href="/analyse?section=measurements&period=all"
|
||||||
>
|
>
|
||||||
Voir dans Analyse
|
Voir dans Analyse
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,14 @@ describe("AnalysisPage", () => {
|
||||||
selected_metric: "weight",
|
selected_metric: "weight",
|
||||||
unit: "kg",
|
unit: "kg",
|
||||||
summaries,
|
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: [
|
points: [
|
||||||
{ timestamp: "2026-08-20T08:00:00Z", value: 83.7 },
|
{ timestamp: "2026-08-20T08:00:00Z", value: 83.7 },
|
||||||
{ timestamp: "2026-09-20T08:00:00Z", value: 85.1 },
|
{ timestamp: "2026-09-20T08:00:00Z", value: 85.1 },
|
||||||
|
|
|
||||||
|
|
@ -1056,6 +1056,28 @@ h2 {
|
||||||
color: var(--lavender);
|
color: var(--lavender);
|
||||||
font-weight: 500;
|
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 {
|
.program-progress p {
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
color: var(--subtext-0);
|
color: var(--subtext-0);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue