Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most other call sites import this service as the default export (import GitHubService from '@/lib/github-service'). To keep imports consistent across the codebase, consider switching this to a default import (or standardize on named imports everywhere).

Suggested change
import { GitHubService } from '@/lib/github-service';
import GitHubService from '@/lib/github-service';

Copilot uses AI. Check for mistakes.
import ContributionHeatmap from '@/components/ContributionHeatmap';

interface ProfilePageProps {
params: Promise<{
Expand Down Expand Up @@ -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 (
<main className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
Expand Down Expand Up @@ -204,6 +209,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</div>
</div>
</div>

{/* Contribution Heatmap */}
<div className="max-w-6xl mx-auto mt-8">
<div className="bg-card rounded-xl p-6">
<ContributionHeatmap calendar={contributionCalendar} totalXp={user.xp} />
</div>
</div>
Comment on lines +214 to +218
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ContributionHeatmap returns null when calendar.weeks is empty, but this page always renders the surrounding card. On API errors / missing token this will produce an empty section with padding. Consider conditionally rendering the whole “Contribution Heatmap” card only when contributionCalendar.weeks.length > 0 (or render an explicit fallback message).

Suggested change
<div className="max-w-6xl mx-auto mt-8">
<div className="bg-card rounded-xl p-6">
<ContributionHeatmap calendar={contributionCalendar} totalXp={user.xp} />
</div>
</div>
{contributionCalendar?.weeks?.length ? (
<div className="max-w-6xl mx-auto mt-8">
<div className="bg-card rounded-xl p-6">
<ContributionHeatmap calendar={contributionCalendar} totalXp={user.xp} />
</div>
</div>
) : null}

Copilot uses AI. Check for mistakes.
</div>
</main>
);
Expand Down
211 changes: 211 additions & 0 deletions components/ContributionHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
'use client';

import { useState } from 'react';
import type { ContributionCalendar } from '@/lib/github-service';

interface ContributionHeatmapProps {
calendar: ContributionCalendar;
totalXp: number;
}

Comment on lines +8 to +10
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalXp is declared in ContributionHeatmapProps and passed from the profile page, but the component doesn’t use it. Either remove this prop to avoid confusion, or use it for the displayed totals so the component API matches the call site.

Suggested change
totalXp: number;
}
}

Copilot uses AI. Check for mistakes.
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 (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium text-muted-foreground text-center">
{(calendar.totalContributions * 5).toLocaleString()} XP in the last year
</h4>
Comment on lines +69 to +71
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The heatmap labels/tooltip treat contributionCount and totalContributions as XP by multiplying by 5, but in this codebase XP is calculated with different weights (e.g., PRs=40, issues=10, reviews=15 in GitHubService.getWeeklyXp, and lifetime XP in lib/xp-calculator.ts). Since contributionCalendar is an aggregate count across contribution types, this will display incorrect XP values; consider either showing “contributions” (not XP) or extending the GraphQL query to fetch per-type counts per day and compute XP consistently.

Copilot uses AI. Check for mistakes.

<div className="overflow-x-auto mx-auto">
<svg
width={dayLabelWidth + calendar.weeks.length * step}
height={headerHeight + 7 * step}
className="block"
>
{/* Month labels */}
{monthLabels.map((m, i) => (
<text
key={i}
x={dayLabelWidth + m.colIndex * step}
y={12}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}

{/* Day labels */}
{DAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={0}
y={headerHeight + i * step + cellSize - 2}
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}

{/* Light mode cells */}
<g className="block dark:hidden">
{calendar.weeks.map((week, wi) =>
week.contributionDays.map((day, di) => {
const level = getIntensityLevel(day.contributionCount);
return (
<rect
key={`${wi}-${di}`}
x={dayLabelWidth + wi * step}
y={headerHeight + di * step}
width={cellSize}
height={cellSize}
rx={2}
fill={LIGHT_FILLS[level]}
onMouseEnter={e => {
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)}
/>
);
}),
)}
</g>

{/* Dark mode cells */}
<g className="hidden dark:block">
{calendar.weeks.map((week, wi) =>
week.contributionDays.map((day, di) => {
const level = getIntensityLevel(day.contributionCount);
return (
<rect
key={`${wi}-${di}`}
x={dayLabelWidth + wi * step}
y={headerHeight + di * step}
width={cellSize}
height={cellSize}
rx={2}
fill={DARK_FILLS[level]}
onMouseEnter={e => {
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)}
/>
);
}),
)}
</g>
</svg>
</div>

{/* Legend */}
<div className="flex items-center justify-center text-xs text-muted-foreground gap-2">
<span>Less</span>
<div className="flex gap-1">
{LIGHT_FILLS.map((_, i) => (
<div key={i} className="w-3 h-3 rounded-sm">
<svg width="12" height="12">
<rect
width="12"
height="12"
rx={2}
className="block dark:hidden"
fill={LIGHT_FILLS[i]}
/>
<rect
width="12"
height="12"
rx={2}
className="hidden dark:block"
fill={DARK_FILLS[i]}
/>
</svg>
</div>
))}
</div>
<span>More</span>
</div>

{/* Tooltip */}
{tooltip && (
<div
className="fixed z-50 px-2 py-1 text-xs bg-popover text-popover-foreground border rounded shadow-md pointer-events-none whitespace-nowrap"
style={{
left: tooltip.x,
top: tooltip.y - 32,
transform: 'translateX(-50%)',
}}
>
{tooltip.text}
</div>
)}
</div>
);
}
9 changes: 5 additions & 4 deletions components/UserLinksDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { usePathname } from "next/navigation";
import { useCallback } from "react";

interface UserLink {
id: string;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
}

Comment on lines 117 to +123
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sortedLinks is computed before the empty-state guard. This does unnecessary work when userLinks is empty and can throw if this component is ever called with an undefined/null value at runtime. Move the early return above the sort (and the !userLinks part is redundant given the prop type).

Suggested change
// Sort links by order
const sortedLinks = [...userLinks].sort((a, b) => a.order - b.order);
if (!userLinks || userLinks.length === 0) {
return null;
}
if (userLinks.length === 0) {
return null;
}
// Sort links by order
const sortedLinks = [...userLinks].sort((a, b) => a.order - b.order);

Copilot uses AI. Check for mistakes.
return (
<div className="space-y-4">
<h3
Expand Down
53 changes: 53 additions & 0 deletions lib/github-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ export interface GitHubUserStats {
avgCommitsPerWeek: number;
}

export interface ContributionDay {
date: string;
contributionCount: number;
color: string;
}

export interface ContributionWeek {
contributionDays: ContributionDay[];
}

export interface ContributionCalendar {
totalContributions: number;
weeks: ContributionWeek[];
}

export interface GitHubActivity {
type:
| 'commit'
Expand Down Expand Up @@ -536,6 +551,44 @@ export class GitHubService {
}
}

async getContributionCalendar(username: string): Promise<ContributionCalendar> {
try {
const query = `
query($username: String!) {
user(login: $username) {
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
date
contributionCount
color
}
}
}
}
}
}
`;

const response: Record<string, unknown> = await this.graphqlWithAuth(query, { username });
const user = response.user as Record<string, unknown> | undefined;

if (!user) {
throw new Error(`User ${username} not found`);
}

const collection = user.contributionsCollection as Record<string, unknown>;
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();
Expand Down
Loading