From 8d4248776b3a3b3a7f7d76746da9705e28f92320 Mon Sep 17 00:00:00 2001 From: Caio Santos Date: Fri, 6 Mar 2026 22:48:55 -0300 Subject: [PATCH] feat: heatmap as XP daily --- app/[username]/page.tsx | 12 ++ components/ContributionHeatmap.tsx | 211 +++++++++++++++++++++++++++++ components/UserLinksDisplay.tsx | 9 +- lib/github-service.ts | 53 ++++++++ 4 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 components/ContributionHeatmap.tsx diff --git a/app/[username]/page.tsx b/app/[username]/page.tsx index 1a0b453..361aab3 100644 --- a/app/[username]/page.tsx +++ b/app/[username]/page.tsx @@ -9,6 +9,8 @@ import UserStats from '@/components/UserStats'; import BadgeWall from '@/components/BadgeSystem'; import UserLinksDisplay from '@/components/UserLinksDisplay'; import Link from 'next/link'; +import { GitHubService } from '@/lib/github-service'; +import ContributionHeatmap from '@/components/ContributionHeatmap'; interface ProfilePageProps { params: Promise<{ @@ -59,6 +61,9 @@ export default async function ProfilePage({ params }: ProfilePageProps) { checkAndUpdateContributorStatus(user.githubUsername).catch(console.error); } + const githubService = new GitHubService(process.env.GITHUB_TOKEN); + const contributionCalendar = await githubService.getContributionCalendar(user.githubUsername); + return (
@@ -204,6 +209,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
+ + {/* Contribution Heatmap */} +
+
+ +
+
); diff --git a/components/ContributionHeatmap.tsx b/components/ContributionHeatmap.tsx new file mode 100644 index 0000000..325fdbf --- /dev/null +++ b/components/ContributionHeatmap.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useState } from 'react'; +import type { ContributionCalendar } from '@/lib/github-service'; + +interface ContributionHeatmapProps { + calendar: ContributionCalendar; + totalXp: number; +} + +const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', '']; + +const LIGHT_FILLS = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']; +const DARK_FILLS = ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353']; + +function getIntensityLevel(count: number): number { + if (count === 0) return 0; + if (count <= 3) return 1; + if (count <= 6) return 2; + if (count <= 9) return 3; + return 4; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr + 'T00:00:00'); + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export default function ContributionHeatmap({ calendar }: ContributionHeatmapProps) { + const [tooltip, setTooltip] = useState<{ + text: string; + x: number; + y: number; + } | null>(null); + + if (!calendar.weeks.length) return null; + + // Build month labels from first day of each week + const monthLabels: { label: string; colIndex: number }[] = []; + let lastMonth = -1; + calendar.weeks.forEach((week, i) => { + const firstDay = week.contributionDays[0]; + if (!firstDay) return; + const month = new Date(firstDay.date + 'T00:00:00').getMonth(); + if (month !== lastMonth) { + monthLabels.push({ + label: new Date(firstDay.date + 'T00:00:00').toLocaleDateString('en-US', { + month: 'short', + }), + colIndex: i, + }); + lastMonth = month; + } + }); + + const cellSize = 12; + const cellGap = 2; + const step = cellSize + cellGap; + const dayLabelWidth = 32; + const headerHeight = 20; + + return ( +
+

+ {(calendar.totalContributions * 5).toLocaleString()} XP in the last year +

+ +
+ + {/* Month labels */} + {monthLabels.map((m, i) => ( + + {m.label} + + ))} + + {/* Day labels */} + {DAY_LABELS.map( + (label, i) => + label && ( + + {label} + + ), + )} + + {/* Light mode cells */} + + {calendar.weeks.map((week, wi) => + week.contributionDays.map((day, di) => { + const level = getIntensityLevel(day.contributionCount); + return ( + { + const rect = e.currentTarget.getBoundingClientRect(); + setTooltip({ + text: `${day.contributionCount * 5} XP on ${formatDate(day.date)}`, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }} + onMouseLeave={() => setTooltip(null)} + /> + ); + }), + )} + + + {/* Dark mode cells */} + + {calendar.weeks.map((week, wi) => + week.contributionDays.map((day, di) => { + const level = getIntensityLevel(day.contributionCount); + return ( + { + const rect = e.currentTarget.getBoundingClientRect(); + setTooltip({ + text: `${day.contributionCount * 5} XP on ${formatDate(day.date)}`, + x: rect.left + rect.width / 2, + y: rect.top, + }); + }} + onMouseLeave={() => setTooltip(null)} + /> + ); + }), + )} + + +
+ + {/* Legend */} +
+ Less +
+ {LIGHT_FILLS.map((_, i) => ( +
+ + + + +
+ ))} +
+ More +
+ + {/* Tooltip */} + {tooltip && ( +
+ {tooltip.text} +
+ )} +
+ ); +} diff --git a/components/UserLinksDisplay.tsx b/components/UserLinksDisplay.tsx index b9f2818..83402be 100644 --- a/components/UserLinksDisplay.tsx +++ b/components/UserLinksDisplay.tsx @@ -1,6 +1,7 @@ 'use client'; import { usePathname } from "next/navigation"; +import { useCallback } from "react"; interface UserLink { id: string; @@ -16,10 +17,6 @@ interface UserLinksDisplayProps { export default function UserLinksDisplay({ userLinks }: UserLinksDisplayProps) { const pathname = usePathname() - if (!userLinks || userLinks.length === 0) { - return null; - } - // Build an augmented URL with UTM params and a `ref` identifying the profile // This runs only when the user clicks (client-side) to avoid SSR/window access. const buildAugmentedUrl = useCallback((originalUrl: string) => { @@ -120,6 +117,10 @@ export default function UserLinksDisplay({ userLinks }: UserLinksDisplayProps) { // Sort links by order const sortedLinks = [...userLinks].sort((a, b) => a.order - b.order); + if (!userLinks || userLinks.length === 0) { + return null; + } + return (

{ + try { + const query = ` + query($username: String!) { + user(login: $username) { + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date + contributionCount + color + } + } + } + } + } + } + `; + + const response: Record = await this.graphqlWithAuth(query, { username }); + const user = response.user as Record | undefined; + + if (!user) { + throw new Error(`User ${username} not found`); + } + + const collection = user.contributionsCollection as Record; + const calendar = collection.contributionCalendar as ContributionCalendar; + + return calendar; + } catch (error) { + console.error('Error fetching contribution calendar:', error); + return { totalContributions: 0, weeks: [] }; + } + } + async getRateLimit() { try { const { data } = await this.octokit.rest.rateLimit.get();