feat(fitness/stats): body-fat trend chart as Δ from baseline

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%)").
This commit is contained in:
2026-04-23 13:57:47 +02:00
parent 8611275bca
commit f807a43d58
5 changed files with 170 additions and 7 deletions
+1 -1
View File
@@ -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/<part> 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?)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.46.6",
"version": "1.46.7",
"private": true,
"type": "module",
"scripts": {
+17 -3
View File
@@ -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}`;
}
}
} : {})
}
})
}
@@ -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
});
};
@@ -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}
<FitnessChart
data={bfChartData}
title={bfChartTitle}
yUnit=" pp"
height="220px"
tooltipFormatter={bfTooltipFormatter}
/>
{/if}
<div class="muscle-nutrition-layout">
{#if ns}
<div class="lifetime-card protein-card">