fitness: improve workouts chart with goal line, max bar width, and full 10-week range
Some checks failed
CI / update (push) Has been cancelled

Add weekly goal as a solid horizontal line on the bar chart via a
custom Chart.js plugin. Cap bar width at 40px. Always show all 10
weeks including empty ones instead of trimming leading zeros.
This commit is contained in:
2026-03-23 12:34:51 +01:00
parent 3ef61c900f
commit 1f83b451de
3 changed files with 52 additions and 23 deletions

View File

@@ -5,13 +5,14 @@
/** /**
* @type {{ * @type {{
* type?: 'line' | 'bar', * type?: 'line' | 'bar',
* data: { labels: string[], datasets: Array<{ label: string, data: (number|null)[], borderColor?: string, backgroundColor?: string, borderWidth?: number, pointRadius?: number, pointBackgroundColor?: string, tension?: number, fill?: boolean|string, order?: number }> }, * data: { labels: string[], datasets: Array<{ label: string, data: (number|null)[], borderColor?: string, backgroundColor?: string, borderWidth?: number, pointRadius?: number, pointBackgroundColor?: string, tension?: number, fill?: boolean|string, order?: number, type?: string, borderDash?: number[], [key: string]: any }> },
* title?: string, * title?: string,
* height?: string, * height?: string,
* yUnit?: string * yUnit?: string,
* goalLine?: number
* }} * }}
*/ */
let { type = 'line', data, title = '', height = '250px', yUnit = '' } = $props(); let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined } = $props();
/** @type {HTMLCanvasElement | undefined} */ /** @type {HTMLCanvasElement | undefined} */
let canvas = $state(undefined); let canvas = $state(undefined);
@@ -47,25 +48,55 @@
const gridColor = dark ? 'rgba(216,222,233,0.1)' : 'rgba(46,52,64,0.08)'; const gridColor = dark ? 'rgba(216,222,233,0.1)' : 'rgba(46,52,64,0.08)';
const plainLabels = [...(data.labels || [])]; const plainLabels = [...(data.labels || [])];
const plainDatasets = (data.datasets || []).map((ds, i) => ({ const plainDatasets = (data.datasets || []).map((ds, i) => {
label: ds.label, const isLine = ds.type === 'line' || (type === 'line' && !ds.type);
data: [...(ds.data || [])], const isBar = ds.type === 'bar' || (type === 'bar' && !ds.type);
borderColor: ds.borderColor || nordColors[i % nordColors.length], return {
backgroundColor: ds.backgroundColor ?? (type === 'bar' ...ds,
? (nordColors[i % nordColors.length]) data: [...(ds.data || [])],
: 'transparent'), borderColor: ds.borderColor || nordColors[i % nordColors.length],
borderWidth: ds.borderWidth ?? (type === 'line' ? 2 : 0), backgroundColor: ds.backgroundColor ?? (isBar
pointRadius: ds.pointRadius ?? (type === 'line' ? 3 : 0), ? (nordColors[i % nordColors.length])
pointBackgroundColor: ds.pointBackgroundColor || ds.borderColor || nordColors[i % nordColors.length], : 'transparent'),
tension: ds.tension ?? 0.3, borderWidth: ds.borderWidth ?? (isLine ? 2 : 0),
fill: ds.fill ?? false, pointRadius: ds.pointRadius ?? (isLine ? 3 : 0),
spanGaps: true, pointBackgroundColor: ds.pointBackgroundColor || ds.borderColor || nordColors[i % nordColors.length],
order: ds.order ?? i tension: ds.tension ?? 0.3,
})); fill: ds.fill ?? false,
spanGaps: true,
order: ds.order ?? i,
...(isBar ? { maxBarThickness: 40 } : {})
};
});
/** @type {import('chart.js').Plugin[]} */
const plugins = [];
if (goalLine != null) {
plugins.push({
id: 'goalLine',
afterDraw(chart) {
const yScale = chart.scales.y;
const xScale = chart.scales.x;
if (!yScale || !xScale) return;
const y = yScale.getPixelForValue(goalLine);
const ctx = chart.ctx;
ctx.save();
ctx.beginPath();
ctx.setLineDash([]);
ctx.strokeStyle = '#EBCB8B';
ctx.lineWidth = 2;
ctx.moveTo(xScale.left, y);
ctx.lineTo(xScale.right, y);
ctx.stroke();
ctx.restore();
}
});
}
chart = new Chart(ctx, { chart = new Chart(ctx, {
type, type,
data: { labels: plainLabels, datasets: plainDatasets }, data: { labels: plainLabels, datasets: plainDatasets },
plugins,
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,

View File

@@ -147,12 +147,9 @@ export const GET: RequestHandler = async ({ locals }) => {
allData.push(weekMap.get(key) ?? 0); allData.push(weekMap.get(key) ?? 0);
} }
// Trim leading empty weeks, but always keep from first week with data
let firstNonZero = allData.findIndex((v) => v > 0);
if (firstNonZero === -1) firstNonZero = allData.length - 1; // show at least current week
const workoutsChart = { const workoutsChart = {
labels: allLabels.slice(firstNonZero), labels: allLabels,
data: allData.slice(firstNonZero) data: allData
}; };
// Build chart-ready weight data with SMA ± 1 std dev confidence band // Build chart-ready weight data with SMA ± 1 std dev confidence band

View File

@@ -187,6 +187,7 @@
data={workoutsChartData} data={workoutsChartData}
title={t('workouts_per_week', lang)} title={t('workouts_per_week', lang)}
height="220px" height="220px"
goalLine={goalWeekly ?? undefined}
/> />
{:else} {:else}
<p class="empty-chart">{t('no_workout_data', lang)}</p> <p class="empty-chart">{t('no_workout_data', lang)}</p>