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) {
|
||||
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) &&
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
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 === '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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue