feat(dashboard): SVG activity calendar and bar charts
This commit is contained in:
parent
cc20cf4bfd
commit
be49a69b2f
1 changed files with 131 additions and 0 deletions
131
src/components/dashboard/activity-calendar.tsx
Normal file
131
src/components/dashboard/activity-calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue