feat(dashboard): SVG activity calendar and bar charts

This commit is contained in:
Dave 2026-09-16 13:51:18 +02:00
parent cc20cf4bfd
commit be49a69b2f

View file

@ -0,0 +1,131 @@
import { buildHeatmap, HEATMAP_LEVELS } from "@/lib/metrics/heatmap";
const CELL = 11;
const GAP = 3;
const LABEL_WIDTH = 26;
/** Level 0 is the empty-day colour; 1..4 step up in brand opacity. */
function fill(level: number): string {
if (level === 0) return "var(--surface-raised)";
const opacity = 0.25 + (level / (HEATMAP_LEVELS - 1)) * 0.75;
return `color-mix(in oklab, var(--brand) ${Math.round(opacity * 100)}%, transparent)`;
}
const MONTHS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
export function ActivityCalendar({
activity,
from,
to,
activeDays,
workdays,
}: {
activity: Record<string, number>;
from: string;
to: string;
activeDays: number;
workdays: number;
}) {
const grid = buildHeatmap(activity, from, to);
if (grid.weeks.length === 0) return null;
const width = LABEL_WIDTH + grid.weeks.length * (CELL + GAP);
const height = 7 * (CELL + GAP) + 18;
// A month label above the first week whose Monday falls in a new month.
const monthLabels: Array<{ x: number; label: string }> = [];
let lastMonth = "";
grid.weeks.forEach((week, index) => {
const month = week[0].date.slice(0, 7);
if (month !== lastMonth) {
monthLabels.push({
x: LABEL_WIDTH + index * (CELL + GAP),
label: MONTHS[Number(week[0].date.slice(5, 7)) - 1],
});
lastMonth = month;
}
});
return (
<section className="space-y-3">
<div className="flex items-baseline gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Activity
</h2>
<p className="text-xs text-text-muted">
<span className="tabular">{activeDays}</span> active of{" "}
<span className="tabular">{workdays}</span> workdays · {from} to {to}
</p>
</div>
<div className="overflow-x-auto">
<svg
width={width}
height={height}
role="img"
aria-label={`Commit activity: ${activeDays} active days out of ${workdays} workdays between ${from} and ${to}`}
>
{monthLabels.map((label) => (
<text
key={`${label.label}-${label.x}`}
x={label.x}
y={10}
className="fill-[var(--text-muted)] text-[9px]"
>
{label.label}
</text>
))}
{["Mon", "Wed", "Fri"].map((name, i) => (
<text
key={name}
x={0}
y={18 + (i * 2) * (CELL + GAP) + CELL - 1}
className="fill-[var(--text-muted)] text-[9px]"
>
{name}
</text>
))}
{grid.weeks.map((week, weekIndex) =>
week.map((day, dayIndex) => {
if (!day.inRange) return null;
return (
<rect
key={day.date}
x={LABEL_WIDTH + weekIndex * (CELL + GAP)}
y={18 + dayIndex * (CELL + GAP)}
width={CELL}
height={CELL}
rx={2}
fill={fill(day.level)}
>
<title>
{day.date}: {day.count}{" "}
{day.count === 1 ? "commit" : "commits"}
</title>
</rect>
);
})
)}
</svg>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span>Less</span>
{Array.from({ length: HEATMAP_LEVELS }, (_, level) => (
<span
key={level}
aria-hidden
className="inline-block h-2.5 w-2.5 rounded-[2px]"
style={{ background: fill(level) }}
/>
))}
<span>More</span>
</div>
</section>
);
}