import { Link } from "@tanstack/react-router";
import { useState } from "react";
import { cn } from "~/utils/cn";
import type { ContributionDay } from "~/server/contributions";
const WEEK = 7;
const LEVELS = ["bg-kumo-base", "bg-accent-950", "bg-accent-800", "bg-accent-600", "bg-accent-400"];
const LEVEL_STEPS = [0, 1, 3, 6, 10];
const TOOLTIP_OFFSET_PX = 8;
const BREAKDOWN_LABELS = [
{ key: "commits", singular: "commit", plural: "commits" },
{ key: "pulls", singular: "pull request event", plural: "pull request events" },
{ key: "issues", singular: "issue opened", plural: "issues opened" },
{ key: "reviews", singular: "comment or review", plural: "comments and reviews" },
{ key: "other", singular: "other event", plural: "other events" },
{ key: "private", singular: "contribution in a private repository", plural: "contributions in private repositories" },
] as const;
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
type Hover = { day: ContributionDay; left: number; top: number };
function level(count: number): string {
let index = 0;
for (const [step, threshold] of LEVEL_STEPS.entries()) if (count >= threshold) index = step;
return LEVELS[index];
}
function formatDay(date: string): string {
return DATE_FORMAT.format(new Date(`${date}T00:00:00Z`));
}
function DayCard({ hover }: { hover: Hover }) {
const { day } = hover;
const lines = day.breakdown
? BREAKDOWN_LABELS.flatMap((label) => {
const value = day.breakdown?.[label.key] ?? 0;
return value > 0 ? [`${value} ${value === 1 ? label.singular : label.plural}`] : [];
})
: [];
return (
{day.count === 0 ? "No contributions" : `${day.count} contribution${day.count === 1 ? "" : "s"}`}
{formatDay(day.date)}
{lines.length > 0 && (
{lines.map((line) => (
- {line}
))}
)}
{day.repos.length > 0 && (
{day.repos.map((repo) => (
{repo}
))}
)}
);
}
type Props = {
days: ContributionDay[];
years: number[];
year: number | null;
owner: string;
/** The day whose details are open below the graph. */
selected: string | null;
actions?: React.ReactNode;
};
function YearList({ years, year, owner }: Pick) {
if (years.length < 2) return null;
const options = [{ value: null, label: "Last year" }, ...years.map((value) => ({ value, label: String(value) }))];
return (
);
}
export function ContributionGraph({ days, years, year, owner, selected, actions }: Props) {
const [hover, setHover] = useState(null);
if (days.length === 0) return null;
const weeks: ContributionDay[][] = [];
for (let index = 0; index < days.length; index += WEEK) weeks.push(days.slice(index, index + WEEK));
const total = days.reduce((sum, day) => sum + day.count, 0);
function show(day: ContributionDay, target: HTMLElement) {
const container = target.closest("[data-contribution-grid]");
if (!(container instanceof HTMLElement)) return;
const box = target.getBoundingClientRect();
const frame = container.getBoundingClientRect();
setHover({ day, left: box.left - frame.left + box.width / 2, top: box.top - frame.top });
}
return (
{total} contribution{total === 1 ? "" : "s"} {year === null ? "in the last year" : `in ${year}`}
{actions}
setHover(null)}>
{weeks.map((week) => (
{week.map((day) => (
({
...previous,
day: selected === day.date ? undefined : day.date,
repoPage: undefined,
activityPage: undefined,
})}
resetScroll={false}
aria-label={`${day.count} contributions on ${formatDay(day.date)}`}
aria-current={selected === day.date ? "date" : undefined}
onMouseEnter={(event) => show(day, event.currentTarget)}
onFocus={(event) => show(day, event.currentTarget)}
onBlur={() => setHover(null)}
className={cn(
"block size-2.5 rounded-xs outline-offset-1 focus-visible:outline-2 focus-visible:outline-accent-500",
level(day.count),
hover?.day.date === day.date && "ring-1 ring-gray-300",
selected === day.date && "ring-2 ring-accent-400",
)}
/>
))}
))}
{hover &&
}
);
}