| name | progress-charts |
| description | Chart.js 4 patterns for fitness progress visualization including per-exercise progress lines with PR markers, weekly volume bar charts, streak heatmap calendars, and goal progress indicators. Use when building or debugging fitness data charts. |
progress-charts
Chart.js 4 patterns for fitness and workout progress visualization.
Per-exercise progress line chart
import { Chart, LineController, LineElement, PointElement, LinearScale, TimeScale, Tooltip, Legend } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import 'chartjs-adapter-date-fns';
Chart.register(LineController, LineElement, PointElement, LinearScale, TimeScale, Tooltip, Legend, annotationPlugin);
interface ExercisePoint {
date: string;
value: number;
is_pr: boolean;
}
export function buildProgressChartConfig(
points: ExercisePoint[],
label: string,
unit: string
): object {
return {
type: 'line',
data: {
datasets: [
{
label,
data: points.map((p) => ({ x: p.date, y: p.value })),
borderColor: '#0891b2',
borderWidth: 2,
pointRadius: points.map((p) => (p.is_pr ? 6 : 3)),
pointBackgroundColor: points.map((p) => (p.is_pr ? '#ca8a04' : '#0891b2')),
pointBorderColor: points.map((p) => (p.is_pr ? '#fde047' : '#0891b2')),
pointBorderWidth: points.map((p) => (p.is_pr ? 2 : 0)),
tension: 0.2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: (ctx: { parsed: { y: number }; dataIndex: number }) => {
const pt = points[ctx.dataIndex];
const pr = pt.is_pr ? ' - PR' : '';
return `${ctx.parsed.y} ${unit}${pr}`;
},
},
},
},
scales: {
x: { type: 'time' as const, time: { unit: 'day' as const } },
y: { grid: { color: 'var(--border)' } },
},
},
};
}
Weekly volume bar chart
interface WeekStats {
week_start: string;
total_volume: number;
session_count: number;
}
export function buildWeeklyVolumeConfig(weeks: WeekStats[]): object {
return {
type: 'bar',
data: {
labels: weeks.map((w) => w.week_start),
datasets: [{
label: 'Weekly Volume (kg)',
data: weeks.map((w) => w.total_volume),
backgroundColor: weeks.map((_, i) =>
i === weeks.length - 1 ? 'rgba(8,145,178,0.9)' : 'rgba(8,145,178,0.5)'
),
borderRadius: 3,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
: {
:
,
},
},
},
: {
: {
: ,
: { : },
: { : },
},
: { : { : } },
},
},
};
}
Streak heatmap (canvas-based, no Chart.js plugin required)
interface DayActivity {
date: string;
workout_count: number;
volume: number;
}
export function drawStreakHeatmap(
canvas: HTMLCanvasElement,
days: DayActivity[]
): void {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const CELL_SIZE = 13;
const CELL_GAP = 3;
const WEEKS = 52;
canvas.width = WEEKS * (CELL_SIZE + CELL_GAP);
canvas.height = 7 * (CELL_SIZE + CELL_GAP);
const maxVolume = Math.max(...days.map((d) => d.volume), 1);
const dayMap = new Map(days.map( [d., d]));
today = ();
startDate = (today);
startDate.(today.() - * );
( week = ; week < ; week++) {
( dow = ; dow < ; dow++) {
d = (startDate);
d.(startDate.() + week * + dow);
key = d.().(, );
activity = dayMap.(key);
intensity = activity
? + (activity. / maxVolume) *
: ;
ctx. =
intensity >
?
: ;
ctx.();
ctx.(
week * ( + ),
dow * ( + ),
,
,
);
ctx.();
}
}
}
Goal progress bar
export function buildGoalProgressConfig(current: number, target: number): object {
const pct = Math.min((current / target) * 100, 100);
return {
type: 'bar',
data: {
labels: [''],
datasets: [
{
label: 'Progress',
data: [pct],
backgroundColor: pct >= 100 ? '#16a34a' : '#0891b2',
borderRadius: 4,
},
{
label: 'Remaining',
data: [Math.max(0, 100 - pct)],
backgroundColor: '#e7e5e4',
borderRadius: 4,
},
],
},
options: {
indexAxis: 'y' as const,
scales: {
x: { stacked: true, max: 100, display: },
: { : , : },
},
: { : { : }, : { : } },
},
};
}
Troubleshooting
Heatmap not rendering on resize
Redraw the canvas on ResizeObserver callback. Store the days data in component state and call drawStreakHeatmap again.
Bar chart clipped at top
Ensure the y-axis max is set to at least max(data) * 1.1 to add headroom. Chart.js auto-scales but can clip if annotations are added.
PR markers not visible on line chart
Confirm pointRadius and pointBackgroundColor arrays are the same length as data.datasets[0].data. A length mismatch silently falls back to default styling.