diff --git a/src/app/[handle]/page.tsx b/src/app/[handle]/page.tsx index 1c75591..a9ad1a0 100644 --- a/src/app/[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -4,6 +4,7 @@ import { cacheGet, cacheSet } from '@/lib/cache'; import Link from 'next/link'; import { ExternalLink, ArrowLeft } from 'lucide-react'; import { CopyButton } from '@/components/copy-button'; +import { ActivityHeatmap } from '@/components/activity-heatmap'; export const revalidate = 300; @@ -59,6 +60,11 @@ type ActiveTask = { difficulty: string | null; }; +type ActivityDay = { + date: string; + count: number; +}; + type ProfileData = { profileId: string; githubHandle: string; @@ -74,10 +80,11 @@ type ProfileData = { timeline: TimelineEvent[]; orgs: OrgEntry[]; activeTasks: ActiveTask[]; + activityHistory: ActivityDay[]; }; async function loadProfileData(handle: string): Promise { - const cacheKey = `profile:v2:${handle}`; + const cacheKey = `profile:v3:${handle}`; const cached = await cacheGet(cacheKey); if (cached) { const { getPublicStreak } = await import('@/app/actions/streak'); @@ -95,6 +102,10 @@ async function loadProfileData(handle: string): Promise { if (!profile) return null; + const oneYearAgo = new Date(); + oneYearAgo.setDate(oneYearAgo.getDate() - 365); + oneYearAgo.setHours(0, 0, 0, 0); + // Fetch all data in parallel const [ prsResult, @@ -103,6 +114,7 @@ async function loadProfileData(handle: string): Promise { claimedRecsResult, recentPRsResult, recentRecsResult, + activityResult, ] = await Promise.all([ // Merged PRs count service @@ -148,6 +160,14 @@ async function loadProfileData(handle: string): Promise { .in('status', ['claimed', 'completed']) .order('claimed_at', { ascending: false }) .limit(5), + + // Public activity from xp_events for the past year + service + .from('xp_events') + .select('created_at') + .eq('user_id', profile.id) + .gte('created_at', oneYearAgo.toISOString()) + .in('source', ['recommended_merge', 'unrecommended_merge', 'help_review']), ]); const prsMerged = prsResult.count ?? 0; @@ -249,6 +269,17 @@ async function loadProfileData(handle: string): Promise { const { getPublicStreak } = await import('@/app/actions/streak'); const { days: streakDays } = await getPublicStreak(profile.id); + // Group events by day in UTC + const activityMap: Record = {}; + for (const event of activityResult.data ?? []) { + const dateStr = new Date(event.created_at).toISOString().slice(0, 10); + activityMap[dateStr] = (activityMap[dateStr] || 0) + 1; + } + const activityHistory = Object.entries(activityMap).map(([date, count]) => ({ + date, + count, + })); + const data: ProfileData = { profileId: profile.id, githubHandle: profile.github_handle, @@ -264,6 +295,7 @@ async function loadProfileData(handle: string): Promise { timeline, orgs, activeTasks, + activityHistory, }; await cacheSet(cacheKey, data, 300); @@ -487,6 +519,11 @@ export default async function PublicProfile({ params }: { params: { handle: stri + + {/* Activity Heatmap */} +
+ +
{/* Right: Orgs + Active Tasks */} diff --git a/src/components/activity-heatmap.tsx b/src/components/activity-heatmap.tsx new file mode 100644 index 0000000..d67f84f --- /dev/null +++ b/src/components/activity-heatmap.tsx @@ -0,0 +1,115 @@ +'use client'; + +type ActivityDay = { + date: string; + count: number; +}; + +interface ActivityHeatmapProps { + activityHistory: ActivityDay[]; +} + +export function ActivityHeatmap({ activityHistory }: ActivityHeatmapProps) { + // Convert history array to a lookup map + const activityMap = new Map(); + for (const item of activityHistory) { + activityMap.set(item.date, item.count); + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Display exactly 53 columns (weeks), each with 7 rows (Sunday to Saturday). + // Find the Sunday of the week that was 52 weeks ago. + const currentDayOfWeek = today.getDay(); // 0 = Sunday, 1 = Monday, etc. + const startOfCurrentWeek = new Date(today); + startOfCurrentWeek.setDate(today.getDate() - currentDayOfWeek); + + const startDate = new Date(startOfCurrentWeek); + startDate.setDate(startOfCurrentWeek.getDate() - 52 * 7); // 52 weeks ago Sunday + + // Generate 371 days (53 weeks) + const days: { dateStr: string; count: number; isFuture: boolean; label: string }[] = []; + const runningDate = new Date(startDate); + + for (let i = 0; i < 371; i++) { + const ymd = runningDate.toISOString().slice(0, 10); + const count = activityMap.get(ymd) || 0; + const isFuture = runningDate > today; + const formattedDate = runningDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + days.push({ + dateStr: ymd, + count, + isFuture, + label: isFuture ? '' : `${count} contribution${count === 1 ? '' : 's'} on ${formattedDate}`, + }); + + runningDate.setDate(runningDate.getDate() + 1); + } + + // Calculate stats for the summary + const totalContributions = activityHistory.reduce((sum, item) => sum + item.count, 0); + + function getColorClass(count: number, isFuture: boolean) { + if (isFuture) return 'bg-transparent cursor-default'; + if (count === 0) return 'bg-[#161b22] border border-[#21262d] hover:border-zinc-500'; + if (count === 1) + return 'bg-emerald-900/60 border border-emerald-800/40 hover:border-emerald-600'; + if (count <= 3) return 'bg-emerald-800 border border-emerald-700/60 hover:border-emerald-500'; + if (count <= 5) return 'bg-emerald-600 border border-emerald-500/80 hover:border-emerald-400'; + return 'bg-emerald-400 border border-emerald-300 hover:border-white'; + } + + return ( +
+
+
+

+ Activity Timeline (Last Year) +

+

+ {totalContributions} Contributions +

+
+
+ Less +
+
+
+
+
+ More +
+
+ +
+ {/* Weekday labels column */} +
+
+
Mon
+
+
Wed
+
+
Fri
+
+
+ + {/* Heatmap Grid */} +
+ {days.map((day) => ( +
+ ))} +
+
+
+ ); +}