@msh-01/react-github-activity
Version:
A beautifully designed, highly customizable React component for displaying GitHub contribution graphs with TypeScript support
405 lines (402 loc) • 13.8 kB
JavaScript
// src/GitHubContributions.tsx
import { useEffect, useState } from "react";
// src/utils.ts
import { clsx } from "clsx";
function cn(...inputs) {
return clsx(inputs);
}
function formatDate(date) {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric"
});
}
function getDateMonthsAgo(months) {
const date = /* @__PURE__ */ new Date();
date.setMonth(date.getMonth() - months);
return date;
}
function getYearBounds(year) {
return {
start: new Date(year, 0, 1),
end: new Date(year, 11, 31)
};
}
function isValidGitHubUsername(username) {
const githubUsernameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/;
return githubUsernameRegex.test(username);
}
function isValidGitHubToken(token) {
const githubTokenRegex = /^gh[pousr]_[A-Za-z0-9_]{36,}$/;
return githubTokenRegex.test(token);
}
// src/GitHubContributions.tsx
import { jsx, jsxs } from "react/jsx-runtime";
var GITHUB_GRAPHQL_API = "https://api.github.com/graphql";
var CONTRIBUTION_LEVELS = {
NONE: "bg-black/5 dark:bg-white/10",
FIRST_QUARTILE: "bg-green-300 dark:bg-green-900",
SECOND_QUARTILE: "bg-green-400 dark:bg-green-700",
THIRD_QUARTILE: "bg-green-600 dark:bg-green-500",
FOURTH_QUARTILE: "bg-green-700 dark:bg-green-300"
};
var MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
];
var MonthLabels = ({ columns }) => {
const monthLabels = [];
let currentMonth = -1;
columns.forEach((column, columnIndex) => {
if (column.length > 0) {
const firstDay = new Date(column[0].date);
const month = firstDay.getMonth();
if (month !== currentMonth) {
monthLabels.push({
month: MONTHS[month],
offset: columnIndex * 11
// 11px per column (10px + 1px gap)
});
currentMonth = month;
}
}
});
return /* @__PURE__ */ jsx("div", { className: "flex mb-2 text-xs text-gray-600 dark:text-gray-400", children: monthLabels.map((label, index) => /* @__PURE__ */ jsx(
"div",
{
className: "absolute",
style: { left: `${label.offset}px` },
children: label.month
},
`${label.month}-${index}`
)) });
};
var ContributionGrid = ({ columns }) => {
return /* @__PURE__ */ jsx("div", { className: "flex gap-0.5", children: columns.map((column, columnIndex) => /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-0.5", children: column.map((day, dayIndex) => /* @__PURE__ */ jsx(
"div",
{
className: cn(
"w-[10px] h-[10px] rounded-[3px]",
CONTRIBUTION_LEVELS[day.contributionLevel]
),
title: `${day.contributionCount} contributions on ${new Date(
day.date
).toLocaleDateString()}`
},
`${columnIndex}-${dayIndex}`
)) }, columnIndex)) });
};
var ContributionLegend = ({
stats,
months,
currentYear
}) => {
return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between mt-4 text-xs text-gray-600 dark:text-gray-400", children: [
/* @__PURE__ */ jsxs("div", { children: [
stats?.totalContributions,
" contributions in",
" ",
months ? `last ${months} months` : currentYear
] }),
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
/* @__PURE__ */ jsx("span", { className: "text-xs", children: "Less" }),
/* @__PURE__ */ jsx("div", { className: "flex gap-0.5", children: Object.entries(CONTRIBUTION_LEVELS).map(
([level, className]) => /* @__PURE__ */ jsx(
"div",
{
className: cn(
"w-[10px] h-[10px] rounded-[3px]",
className
)
},
level
)
) }),
/* @__PURE__ */ jsx("span", { className: "text-xs", children: "More" })
] })
] });
};
var ContributionStatsGrid = ({ stats }) => {
return /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 md:grid-cols-4 gap-4 mt-6", children: [
/* @__PURE__ */ jsxs("div", { children: [
/* @__PURE__ */ jsx("div", { className: "text-xs uppercase text-gray-600 dark:text-gray-400", children: "Total Contributions" }),
/* @__PURE__ */ jsx("div", { className: "text-2xl font-bold", children: stats.totalContributions })
] }),
/* @__PURE__ */ jsxs("div", { children: [
/* @__PURE__ */ jsx("div", { className: "text-xs uppercase text-gray-600 dark:text-gray-400", children: "Daily Average" }),
/* @__PURE__ */ jsx("div", { className: "text-2xl font-bold", children: stats.avgContributionsPerDay })
] }),
/* @__PURE__ */ jsxs("div", { children: [
/* @__PURE__ */ jsx("div", { className: "text-xs uppercase text-gray-600 dark:text-gray-400", children: "Longest Streak" }),
/* @__PURE__ */ jsxs("div", { className: "text-2xl font-bold", children: [
stats.longestStreak,
" days"
] })
] }),
/* @__PURE__ */ jsxs("div", { children: [
/* @__PURE__ */ jsx("div", { className: "text-xs uppercase text-gray-600 dark:text-gray-400", children: "Current Streak" }),
/* @__PURE__ */ jsxs("div", { className: "text-2xl font-bold", children: [
stats.currentStreak,
" days"
] })
] })
] });
};
var GitHubContributions = ({
username,
token,
showStats = false,
year,
months,
showLabels = true,
daysPerColumn = 7,
className
}) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [, setError] = useState(null);
const currentYear = year || (/* @__PURE__ */ new Date()).getFullYear();
const generateEmptyContributionData = (year2, monthsBack) => {
let startDate;
let endDate;
if (monthsBack) {
endDate = /* @__PURE__ */ new Date();
startDate = new Date(endDate);
startDate.setMonth(endDate.getMonth() - monthsBack);
} else {
startDate = new Date(year2, 0, 1);
endDate = new Date(year2, 11, 31);
}
const weeks = [];
const currentDate = new Date(startDate);
const startSunday = new Date(currentDate);
startSunday.setDate(currentDate.getDate() - currentDate.getDay());
let weekDays = [];
const iterDate = new Date(startSunday);
while (iterDate <= endDate) {
weekDays.push({
date: iterDate.toISOString().split("T")[0],
contributionCount: 0,
contributionLevel: "NONE"
});
if (weekDays.length === 7) {
weeks.push({ contributionDays: [...weekDays] });
weekDays = [];
}
iterDate.setDate(iterDate.getDate() + 1);
}
if (weekDays.length > 0) {
weeks.push({ contributionDays: weekDays });
}
return {
totalContributions: 0,
weeks,
firstContribution: null,
lastContribution: null
};
};
useEffect(() => {
const fetchContributions = async () => {
try {
setLoading(true);
setError(null);
let fromDate;
let toDate;
if (months) {
const today = /* @__PURE__ */ new Date();
const startDate = new Date(today);
startDate.setMonth(today.getMonth() - months);
fromDate = startDate.toISOString();
toDate = today.toISOString();
} else {
fromDate = `${currentYear}-01-01T00:00:00Z`;
toDate = `${currentYear}-12-31T23:59:59Z`;
}
const query = `
query($username: String!, $from: DateTime!, $to: DateTime!) {
user(login: $username) {
contributionsCollection(from: $from, to: $to) {
contributionCalendar {
totalContributions
weeks {
contributionDays {
date
contributionCount
contributionLevel
}
}
}
}
}
}
`;
const headers = {
"Content-Type": "application/json",
Authorization: `token ${token}`
};
console.log("GitHub API Request:", {
url: GITHUB_GRAPHQL_API,
tokenPreview: `${token.substring(0, 8)}...`,
username,
dateRange: months ? `${months} months` : `year ${currentYear}`,
fromDate,
toDate
});
const response = await fetch(GITHUB_GRAPHQL_API, {
method: "POST",
headers,
body: JSON.stringify({
query,
variables: {
username,
from: fromDate,
to: toDate
}
})
});
if (!response.ok) {
const errorText = await response.text();
console.error("GitHub API Error:", {
status: response.status,
statusText: response.statusText,
body: errorText,
headers: Object.fromEntries(response.headers.entries())
});
if (response.status === 403 && errorText.includes("rate limit exceeded")) {
throw new Error(
"GitHub API rate limit exceeded. Please check your token or try again later."
);
}
throw new Error(
`GitHub API error (${response.status}): ${response.statusText}. ${errorText}`
);
}
const result = await response.json();
if (result.errors) {
console.error("GraphQL errors:", result.errors);
throw new Error(`GraphQL error: ${result.errors[0].message}`);
}
const contributionData = result.data.user.contributionsCollection.contributionCalendar;
const allDays = contributionData.weeks.flatMap(
(week) => week.contributionDays
);
const daysWithContributions = allDays.filter(
(day) => day.contributionCount > 0
);
const firstContribution = daysWithContributions.length > 0 ? daysWithContributions[0].date : null;
const lastContribution = daysWithContributions.length > 0 ? daysWithContributions[daysWithContributions.length - 1].date : null;
setData({
totalContributions: contributionData.totalContributions,
weeks: contributionData.weeks,
firstContribution,
lastContribution
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "An error occurred";
console.error("GitHub Contributions Error:", errorMessage);
setError(errorMessage);
const emptyData = generateEmptyContributionData(currentYear, months);
setData(emptyData);
} finally {
setLoading(false);
}
};
fetchContributions();
}, [username, token, currentYear, months]);
const calculateStats = () => {
if (!data) return null;
const allDays = data.weeks.flatMap((week) => week.contributionDays);
const daysWithContributions = allDays.filter(
(day) => day.contributionCount > 0
);
const totalDays = allDays.length;
const avgContributionsPerDay = data.totalContributions / totalDays;
const longestStreak = calculateLongestStreak(allDays);
const currentStreak = calculateCurrentStreak(allDays);
return {
totalContributions: data.totalContributions,
avgContributionsPerDay: avgContributionsPerDay.toFixed(2),
totalActiveDays: daysWithContributions.length,
longestStreak,
currentStreak
};
};
const calculateLongestStreak = (days) => {
let maxStreak = 0;
let currentStreak = 0;
for (const day of days) {
if (day.contributionCount > 0) {
currentStreak++;
maxStreak = Math.max(maxStreak, currentStreak);
} else {
currentStreak = 0;
}
}
return maxStreak;
};
const calculateCurrentStreak = (days) => {
let streak = 0;
const today = /* @__PURE__ */ new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
for (let i = days.length - 1; i >= 0; i--) {
const dayDate = new Date(days[i].date);
if (days[i].contributionCount > 0) {
streak++;
} else if (dayDate < yesterday) {
break;
}
}
return streak;
};
const regroupDaysByColumns = (weeks) => {
const allDays = weeks.flatMap((week) => week.contributionDays);
const columns2 = [];
for (let i = 0; i < allDays.length; i += daysPerColumn) {
columns2.push(allDays.slice(i, i + daysPerColumn));
}
return columns2;
};
if (loading) {
return /* @__PURE__ */ jsx("div", { className: cn("flex items-center justify-center p-8", className), children: /* @__PURE__ */ jsx("div", { className: "animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white" }) });
}
if (!data) {
return /* @__PURE__ */ jsx("div", { className: cn("p-4 text-gray-500 dark:text-gray-400", className), children: "No contribution data found." });
}
const stats = calculateStats();
const columns = regroupDaysByColumns(data.weeks);
return /* @__PURE__ */ jsx("div", { className: cn("space-y-4", className), children: /* @__PURE__ */ jsxs("div", { className: "relative", children: [
showLabels && /* @__PURE__ */ jsx("div", { className: "relative mb-8", children: /* @__PURE__ */ jsx(MonthLabels, { columns }) }),
/* @__PURE__ */ jsx("div", { className: "flex", children: /* @__PURE__ */ jsx(ContributionGrid, { columns }) }),
showLabels && /* @__PURE__ */ jsx(
ContributionLegend,
{
stats: stats || void 0,
months,
currentYear
}
),
showStats && stats && /* @__PURE__ */ jsx(ContributionStatsGrid, { stats })
] }) });
};
export {
GitHubContributions,
cn,
formatDate,
getDateMonthsAgo,
getYearBounds,
isValidGitHubToken,
isValidGitHubUsername
};