From f807a43d58840253c6fbf29583fa20c65adf48a4 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 23 Apr 2026 13:57:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(fitness/stats):=20body-fat=20trend=20chart?= =?UTF-8?q?=20as=20=CE=94=20from=20baseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the weight chart pipeline (SMA + ±1σ confidence band) for body-fat %, but emits deltas from the first displayed measurement so the y-axis shows change instead of raw numbers. Title surfaces the baseline (e.g. "Body Fat · Δ from 18.2%"), y-unit is "pp" (percentage points), colours are purple trend on top of an orange raw-data line so it reads differently from weight's green+blue at a glance. FitnessChart gained two shared upgrades: `interaction.mode = 'index'` on line charts so hovering the x-axis shows tooltips for every dataset (including the trend line whose pointRadius is 0), and a `σ` dataset filter so the confidence band doesn't clutter the tooltip. A new optional `tooltipFormatter` prop lets callers format the hover label; the BF chart uses it to show the signed delta + reconstructed absolute % for raw points and additionally the ±1σ window for trend points (e.g. "+0.30 ±0.45 pp · 18.5% (18.0–18.9%)"). --- TODO.md | 2 +- package.json | 2 +- .../components/fitness/FitnessChart.svelte | 20 +++- .../api/fitness/stats/overview/+server.ts | 55 ++++++++++- .../fitness/[stats=fitnessStats]/+page.svelte | 98 +++++++++++++++++++ 5 files changed, 170 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index b2fd8f97..e1087419 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,7 @@ [x] on /fitness/measure (resp. their associated logging API routes), consolidate measurements by day. If we want to log another measurement, overwriting an old one, show a warning to indicate this. disparate measurements (e.g., weight and bodyfat) should not show this warning but simply be merged into one log entry for that day. [x] on /fitness/measure in the past measurments tab, show more than "Body measurements only" if we don't have Bodyweight logged. we can be a bit more elaborate in our syntax here tbh. [x] add a button on /fitness/measure/body-parts for each measurement directly below to say "Same value", instead of having to hit +, then - to lock in same number -[ ] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph) +[x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph) [ ] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking? [ ] on /fitness/stats/histoy/ for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?) diff --git a/package.json b/package.json index 7f0b63b4..6acdad86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.46.6", + "version": "1.46.7", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/fitness/FitnessChart.svelte b/src/lib/components/fitness/FitnessChart.svelte index e80e1fe9..c915539c 100644 --- a/src/lib/components/fitness/FitnessChart.svelte +++ b/src/lib/components/fitness/FitnessChart.svelte @@ -10,10 +10,11 @@ * title?: string, * height?: string, * yUnit?: string, - * goalLine?: number + * goalLine?: number, + * tooltipFormatter?: (value: number, datasetIndex: number, dataIndex: number, label: string) => string * }} */ - let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined } = $props(); + let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined, tooltipFormatter = undefined } = $props(); /** @type {HTMLCanvasElement | undefined} */ let canvas = $state(undefined); @@ -109,6 +110,7 @@ responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, + interaction: type === 'line' ? { mode: 'index', intersect: false } : undefined, scales: { x: useTimeAxis ? { type: 'time', @@ -156,7 +158,19 @@ bodyColor: dark ? '#D8DEE9' : '#3B4252', borderWidth: 0, cornerRadius: 8, - padding: 10 + padding: 10, + filter: (/** @type {any} */ ctx) => !(ctx.dataset?.label ?? '').includes('σ'), + ...(tooltipFormatter ? { + callbacks: { + label: (/** @type {any} */ ctx) => { + const v = ctx.parsed.y; + const label = ctx.dataset.label ?? ''; + if (v == null) return label; + const formatted = tooltipFormatter(v, ctx.datasetIndex, ctx.dataIndex, label); + return `${label}: ${formatted}`; + } + } + } : {}) } }) } diff --git a/src/routes/api/fitness/stats/overview/+server.ts b/src/routes/api/fitness/stats/overview/+server.ts index e58c9c0b..17e5245a 100644 --- a/src/routes/api/fitness/stats/overview/+server.ts +++ b/src/routes/api/fitness/stats/overview/+server.ts @@ -41,7 +41,7 @@ export const GET: RequestHandler = async ({ locals }) => { // Use stored kcalEstimate when available; fall back to on-the-fly for legacy sessions const DISPLAY_LIMIT = 30; const SMA_LOOKBACK = 6; // w - 1 where w = 7 max - const [allSessions, weightMeasurements] = await Promise.all([ + const [allSessions, weightMeasurements, bfMeasurements] = await Promise.all([ WorkoutSession.find( { createdBy: user.nickname }, { 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 } @@ -49,9 +49,14 @@ export const GET: RequestHandler = async ({ locals }) => { BodyMeasurement.find( { createdBy: user.nickname, weight: { $ne: null } }, { date: 1, weight: 1, _id: 0 } + ).sort({ date: -1 }).limit(DISPLAY_LIMIT + SMA_LOOKBACK).lean(), + BodyMeasurement.find( + { createdBy: user.nickname, bodyFatPercent: { $ne: null } }, + { date: 1, bodyFatPercent: 1, _id: 0 } ).sort({ date: -1 }).limit(DISPLAY_LIMIT + SMA_LOOKBACK).lean() ]); weightMeasurements.reverse(); // back to chronological order + bfMeasurements.reverse(); let totalTonnage = 0; let totalCardioKm = 0; @@ -206,13 +211,59 @@ export const GET: RequestHandler = async ({ locals }) => { weightChart.lower.push(round(mean - std)); } + // Build body-fat chart as Δ from the first displayed point — emphasises + // relative change over noisy absolute numbers. + const bfChart: { + labels: string[]; + dates: string[]; + data: number[]; + sma: (number | null)[]; + upper: (number | null)[]; + lower: (number | null)[]; + baseline: number | null; + } = { labels: [], dates: [], data: [], sma: [], upper: [], lower: [], baseline: null }; + + if (bfMeasurements.length > 0) { + const allBf: number[] = bfMeasurements.map((m) => m.bodyFatPercent!); + const displayStartBf = Math.max(0, allBf.length - DISPLAY_LIMIT); + const baseline = allBf[displayStartBf]; + bfChart.baseline = Math.round(baseline * 100) / 100; + + for (let idx = displayStartBf; idx < bfMeasurements.length; idx++) { + const d = new Date(bfMeasurements[idx].date); + bfChart.labels.push( + d.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ); + bfChart.dates.push(d.toISOString()); + bfChart.data.push(Math.round((allBf[idx] - baseline) * 100) / 100); + } + + const wBf = Math.min(7, Math.max(2, Math.floor(allBf.length / 2))); + for (let idx = displayStartBf; idx < allBf.length; idx++) { + const k = Math.min(wBf, idx + 1); + let sum = 0; + for (let j = idx - k + 1; j <= idx; j++) sum += allBf[j]; + const mean = sum / k; + let variance = 0; + for (let j = idx - k + 1; j <= idx; j++) variance += (allBf[j] - mean) ** 2; + const std = k > 1 + ? Math.sqrt(variance / (k - 1)) * Math.sqrt(wBf / k) + : Math.sqrt(variance) * Math.sqrt(wBf); + const round = (v: number) => Math.round(v * 100) / 100; + bfChart.sma.push(round(mean - baseline)); + bfChart.upper.push(round(mean - baseline + std)); + bfChart.lower.push(round(mean - baseline - std)); + } + } + return json({ totalWorkouts, totalTonnage: Math.round(totalTonnage / 1000 * 10) / 10, totalCardioKm: Math.round(totalCardioKm * 10) / 10, kcalEstimate, workoutsChart, - weightChart + weightChart, + bfChart }); }; diff --git a/src/routes/fitness/[stats=fitnessStats]/+page.svelte b/src/routes/fitness/[stats=fitnessStats]/+page.svelte index 4af07914..06a0b09a 100644 --- a/src/routes/fitness/[stats=fitnessStats]/+page.svelte +++ b/src/routes/fitness/[stats=fitnessStats]/+page.svelte @@ -38,6 +38,9 @@ const primary = $derived(dark ? '#88C0D0' : '#5E81AC'); const primaryFill = $derived(dark ? 'rgba(136, 192, 208, 0.15)' : 'rgba(94, 129, 172, 0.15)'); + // Purple trend + orange raw so BF reads differently from weight at a glance + const bfAccent = $derived('#B48EAD'); + const bfAccentFill = $derived(dark ? 'rgba(180, 142, 173, 0.2)' : 'rgba(180, 142, 173, 0.16)'); const stats = $derived(data.stats ?? {}); @@ -111,6 +114,91 @@ const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null)); + const hasSmaBf = $derived(stats.bfChart?.sma?.some((/** @type {any} */ v) => v !== null)); + + const bfChartData = $derived({ + labels: stats.bfChart?.labels ?? [], + dates: stats.bfChart?.dates, + datasets: [ + ...(hasSmaBf ? [ + { + label: '± 1σ', + data: stats.bfChart.upper, + borderColor: 'transparent', + backgroundColor: bfAccentFill, + fill: '+1', + pointRadius: 0, + borderWidth: 0, + tension: 0.3, + order: 2 + }, + { + label: '± 1σ (lower)', + data: stats.bfChart.lower, + borderColor: 'transparent', + backgroundColor: 'transparent', + fill: false, + pointRadius: 0, + borderWidth: 0, + tension: 0.3, + order: 2 + }, + { + label: 'Trend', + data: stats.bfChart.sma, + borderColor: bfAccent, + pointRadius: 0, + borderWidth: 3, + tension: 0.3, + order: 1 + } + ] : []), + { + label: 'Body fat Δ (pp)', + data: stats.bfChart?.data ?? [], + borderColor: '#D08770', + borderWidth: hasSmaBf ? 1 : 2, + pointRadius: 3, + order: 0 + } + ] + }); + + /** + * Tooltip: show signed delta + absolute %, and for the trend line the ±1σ range. + * @param {number} v + * @param {number} _datasetIndex + * @param {number} dataIndex + * @param {string} label + */ + function bfTooltipFormatter(v, _datasetIndex, dataIndex, label) { + const baseline = stats.bfChart?.baseline ?? 0; + const abs = baseline + v; + const sign = v > 0 ? '+' : v < 0 ? '' : '±'; + const base = `${sign}${v.toFixed(2)} pp · ${abs.toFixed(1)}%`; + if (label === 'Trend') { + const upper = stats.bfChart?.upper?.[dataIndex]; + const lower = stats.bfChart?.lower?.[dataIndex]; + if (upper != null && lower != null) { + const sigma = (upper - lower) / 2; + const absLower = baseline + lower; + const absUpper = baseline + upper; + return `${sign}${v.toFixed(2)} ±${sigma.toFixed(2)} pp · ${abs.toFixed(1)}% (${absLower.toFixed(1)}–${absUpper.toFixed(1)}%)`; + } + } + return base; + } + + const bfChartTitle = $derived.by(() => { + const baseline = stats.bfChart?.baseline; + const label = t('body_fat', lang).replace(' %', '').replace(' (%)', ''); + if (baseline == null) return label; + const suffix = lang === 'en' + ? `Δ from ${baseline.toFixed(1)}%` + : `Δ von ${baseline.toFixed(1)}%`; + return `${label} · ${suffix}`; + }); + const weightChartData = $derived({ labels: stats.weightChart?.labels ?? [], dates: stats.weightChart?.dates, @@ -253,6 +341,16 @@ /> {/if} + {#if (stats.bfChart?.data?.length ?? 0) > 1} + + {/if} +
{#if ns}