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:
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.46.6",
|
||||
"version": "1.46.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user