Skip to content
back to blog
Tutorial·September 19, 2026·8 min read

How I Built a Live GitHub Contribution Chart for My Next.js Portfolio

GitHub has no public API for the contribution graph, so I scrape the public page server-side and draw my own heatmap with a year filter. Here's how the whole thing goes together.

Every GitHub profile has that little graph — the grid of colored squares showing how much you committed each day. I wanted something like it on my portfolio, updating by itself instead of being a static screenshot I'd have to refresh every few weeks.

That turned out to be more annoying than it sounds:

  • GitHub doesn't have an official API that hands you the contribution graph.
  • Calling the page directly from the browser hit CORS, and the response is a ~226KB HTML file I didn't want anyone caching on the client side.

So the approach became: a small Next.js API route fetches the public contributions page on the server, parses the HTML into clean JSON, caches it for a few hours, and a React component just draws the heatmap. That's the whole architecture. Let me show you how it works.

Where the data lives

There's a plain HTML page behind that graph, and every profile renders it:

https://github.com/users/<username>/contributions

Hit that URL and you get a table covering the last 12 months. You can also ask for any specific year with from and to params:

https://github.com/users/<username>/contributions?from=2025-01-01&to=2025-12-31

If you're reading this on a computer, just open those URLs and view source. Every little square is a <td> with a data-date and a data-level (0 to 4). The exact per-day counts live in a <tool-tip> element that points at the cell's id.

So the plan is simple: fetch this page in an API route, rip out what I need, and send the client tidy JSON.

Step 1 — an API route that parses GitHub's HTML

The route lives at app/api/github-contributions/route.ts. It checks the year query param and either returns the last twelve months or a specific year:

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const year = searchParams.get("year");

  try {
    if (year && /^\d{4}$/.test(year)) {
      return json(await fetchYear(year));
    }
    return json(await fetchLastYear());
  } catch {
    return NextResponse.json({ error: "Unable to load contributions" }, { status: 502 });
  }
}

The fetch itself is nothing special — a plain fetch with a browser-like user agent and a timeout, so a slow GitHub doesn't hang my route:

async function fetchPage(suffix: string): Promise<string> {
  const res = await fetch(`${GITHUB_PAGE_URL}${suffix}`, {
    headers: { "user-agent": "portfolio-app" },
    cache: "no-store",
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`GitHub responded ${res.status}`);
  return res.text();
}

Parsing the calendar

The calendar table has seven rows — one per weekday, starting with Sunday. Each row holds the day cells for every week in the range.

The part that caught me out: not every <td> in a row is a day. There's a label cell at the front of each row (the "ContributionCalendar-label"), and yearly views also throw in empty placeholder cells with no data-date just to keep the week columns aligned. I keep those as null so everything still lines up:

function parseRows(html: string): ParsedRow[] {
  const tbody = html.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "";
  const rowsHtml = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)].map((m) => m[1]);

  return rowsHtml.map((rowHtml) => {
    const labelMatch = rowHtml.match(/ContributionCalendar-label[^>]*>[\s\S]*?<span[^>]*>([^<]+)<\/span>/);
    const label = labelMatch ? labelMatch[1] : "";

    const cells: (ParsedDay | null)[] = [];
    const tagRe = /<td\b[^>]*>/g;
    let m: RegExpExecArray | null;

    while ((m = tagRe.exec(rowHtml))) {
      const tag = m[0];
      if (tag.includes("ContributionCalendar-label")) continue;

      const dateMatch = tag.match(/data-date="([^"]+)"/);
      const levelMatch = tag.match(/data-level="([0-4])"/);
      if (!dateMatch || !levelMatch) {
        cells.push(null);
        continue;
      }

      const idMatch = tag.match(/id="(contribution-day-component-[^"]+)"/);
      const count = idMatch ? countFromTooltip(html, idMatch[1]) : 0;
      cells.push({ date: dateMatch[1], level: Number(levelMatch[1]), count });
    }

    return { label, cells };
  });
}

Since the rows run Sunday through Saturday, I flip them so each column becomes one week — which matches how the graph is actually drawn:

function buildWeeks(rows: ParsedRow[]): (ParsedDay | null)[][] {
  const weekCount = Math.max(0, ...rows.map((r) => r.cells.length));
  const weeks: (ParsedDay | null)[][] = [];
  for (let w = 0; w < weekCount; w++) {
    weeks.push(rows.map((r) => r.cells[w] ?? null));
  }
  return weeks;
}

Grabbing exact counts from the tooltips

Here's the annoying part: the day cells don't carry their own counts. The number lives in a separate <tool-tip> element. Each day cell has an id like contribution-day-component-0-1, and its matching tooltip holds the "N contributions" text:

function countFromTooltip(html: string, cellId: string): number {
  const tooltipRe = new RegExp(
    `<tool-tip[^>]*for="${escapeRegex(cellId)}"[^>]*>([^<]*)</tool-tip>`
  );
  const m = tooltipRe.exec(html);
  if (!m) return 0;
  const text = m[1].trim();
  const countMatch = text.match(/(\d+)\s+contributions?/);
  return countMatch ? Number(countMatch[1]) : 0;
}

The header line — "219 contributions in 2025" — is where the yearly total comes from:

const totalMatch = html.match(/class="f4 text-normal mb-2">\s*([\d,]+)\s+contributions?/);
const total = totalMatch ? Number(totalMatch[1].replace(/,/g, "")) : 0;

Step 2 — caching (don't scrape GitHub on every page view)

Fetching a 226KB page for every visitor would be rude to GitHub and slow for me. Two layers of caching fix that:

  1. In-process cache — a module-level Map with a 6-hour TTL, keyed per request (last, year:2025, and the probed years list).
  2. CDN hint — an s-maxage header so an edge cache can also hold the response:
const cache = new Map<string, { at: number; data: unknown }>();

function cacheGet<T>(key: string): T | null {
  const hit = cache.get(key);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.data as T;
  return null;
}

function json(data: unknown) {
  return NextResponse.json(data, {
    headers: { "Cache-Control": "public, max-age=0, s-maxage=3600" },
  });
}

Step 3 — the year filter, GitHub-style

For the year switcher I first had to know which years actually have contributions. So this route checks the last 8 calendar years, caches each parsed result, and keeps only the ones with a non-zero total:

async function getAvailableYears(): Promise<string[]> {
  const cached = cacheGet<string[]>("years");
  if (cached) return cached;

  const current = new Date().getFullYear();
  const candidates = Array.from({ length: 8 }, (_, i) => String(current - i));
  const settled = await Promise.allSettled(
    candidates.map(async (y) => {
      const hit = cacheGet<ContributionData>(`year:${y}`);
      if (hit) return hit;
      const html = await fetchPage(`?from=${y}-01-01&to=${y}-12-31`);
      const data = parseContributions(html);
      cacheSet(`year:${y}`, data);
      return data;
    })
  );

  const years: string[] = [];
  settled.forEach((res, i) => {
    if (res.status === "fulfilled" && res.value.total > 0) years.push(candidates[i]);
  });
  years.sort((a, b) => Number(b) - Number(a));

  cacheSet("years", years);
  return years;
}

This double-packs too: just by probing the years it pre-fills the year:* cache, so when someone clicks a year they usually get an instant hit instead of triggering another live scrape.

Step 4 — the React heatmap component

The client component (GitHubContributionChart.tsx) fetches the route and draws the weeks on a CSS grid. No canvas, no chart library — just a grid where each cell's column is the week index and its row is the weekday:

type Week = (Day | null)[];

function ContributionGrid({
  weeks,
  rows,
  label,
}: {
  weeks: Week[];
  rows: ChartData["rows"];
  label: string;
}) {
  return (
    <div
      className="grid w-max"
      style={{
        gridTemplateColumns: `${LABEL_WIDTH}px repeat(${weeks.length}, ${CELL}px)`,
        gap: GAP,
      }}
      role="img"
      aria-label={label}
    >
      {rows.map((row, d) => (
        <div
          key={d}
          style={{ gridColumn: 1, gridRow: d + 1, height: CELL }}
        >
          {row.label || ROW_LABELS[d]}
        </div>
      ))}

      {weeks.map((week, w) =>
        week.map((day, d) => (
          <div
            key={`${w}-${d}`}
            title={day ? formatCount(day.count, day.date) : undefined}
            aria-label={day ? formatCount(day.count, day.date) : "no data"}
            className="rounded-[2px]"
            style={{
              gridColumn: w + 2,
              gridRow: d + 1,
              width: CELL,
              height: CELL,
              background: day ? LEVEL_BG[day.level] : "transparent",
            }}
          />
        ))
      )}
    </div>
  );
}

The square colors use color-mix against the site's accent variable, so the graph picks up whatever theme is active — light or dark — without any extra work:

const LEVEL_BG = [
  "color-mix(in srgb, var(--foreground) 7%, transparent)",
  "color-mix(in srgb, var(--accent) 25%, transparent)",
  "color-mix(in srgb, var(--accent) 45%, transparent)",
  "color-mix(in srgb, var(--accent) 65%, transparent)",
  "color-mix(in srgb, var(--accent) 90%, transparent)",
];

Month labels above the grid and weekday labels down the side are handled the same way GitHub does it — one label per run of consecutive weeks that share a month.

Gotchas I hit along the way

These cost me the most time. Hope they save you an afternoon:

  • Placeholder cells must stay as null. If you throw them away, weeks in yearly views quietly shift and suddenly your columns don't line up with the month labels anymore. Confusing to debug.
  • React StrictMode double-mounts effects in development. My first version aborted the initial fetch and then read that abort as an error, leaving the chart blank. The fix: ignore errors named AbortError in .catch, and only set the error state on real failures.
  • Stale dev-server module. Editing the route while next dev was running served an old in-memory copy of the parsing logic — the one that dropped the null placeholders. A full dev-server restart (not just HMR) fixed it.
  • The tooltip regex needs escaping. Cell ids go straight into a RegExp, and one stray [ or | in an id will break the match, so they must be escaped first.

Make it yours

The whole thing — parsing route, cache, year probing, and the client chart — is running on my portfolio at acharyanischal.com.np/#contributions, and the full source is in this repo under app/api/github-contributions/route.ts and components/home/GitHubContributionChart.tsx.

To drop it into your own Next.js project:

  1. Copy the API route and swap the username for yours.
  2. Copy the chart component.
  3. Render <GitHubContributionChart /> wherever you want it on a page.
  4. Deploy and watch it fill in as you keep shipping.

No API key, no database, no third-party service. Just a small server-side scrape, a 6-hour cache, and a piece of GitHub I'd never seen anyone use this way.

Full source copy-paste, enjoy! I hope this helps😁😁😁

Both files below if you'd rather copy them whole instead of stitching the snippets together. Swap the username, drop the component in, done.

app/api/github-contributions/route.ts

import { NextResponse } from "next/server";
import { profile } from "@/lib/data/profile";

const username = profile.socialHandles.github.replace("@", "");
const GITHUB_PAGE_URL = `https://github.com/users/${username}/contributions`;
const TTL_MS = 6 * 60 * 60 * 1000;

type ParsedDay = { date: string; level: number; count: number };
type ParsedRow = { label: string; cells: (ParsedDay | null)[] };
type ContributionData = {
  username: string;
  url: string;
  total: number;
  rows: ParsedRow[];
  weeks: (ParsedDay | null)[][];
  range: { from: string | null; to: string | null };
};

const cache = new Map<string, { at: number; data: unknown }>();

function cacheGet<T>(key: string): T | null {
  const hit = cache.get(key);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.data as T;
  return null;
}

function cacheSet(key: string, data: unknown) {
  cache.set(key, { at: Date.now(), data });
}

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const year = searchParams.get("year");

  try {
    if (year && /^\d{4}$/.test(year)) {
      const data = await fetchYear(year);
      return json(data);
    }
    const data = await fetchLastYear();
    return json(data);
  } catch {
    return NextResponse.json({ error: "Unable to load contributions" }, { status: 502 });
  }
}

function json(data: unknown) {
  return NextResponse.json(data, {
    headers: { "Cache-Control": "public, max-age=0, s-maxage=3600" },
  });
}

async function fetchPage(suffix: string): Promise<string> {
  const res = await fetch(`${GITHUB_PAGE_URL}${suffix}`, {
    headers: { "user-agent": "portfolio-app" },
    cache: "no-store",
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`GitHub responded ${res.status}`);
  return res.text();
}

async function fetchLastYear() {
  const cached = cacheGet<unknown>("last");
  if (cached) return cached;

  const html = await fetchPage("");
  const data = parseContributions(html);
  const years = await getAvailableYears();
  const withYears = { ...data, years };
  cacheSet("last", withYears);
  return withYears;
}

async function fetchYear(year: string) {
  const cached = cacheGet<ContributionData>(`year:${year}`);
  if (cached) return { ...cached, year, years: await getAvailableYears() };

  const html = await fetchPage(`?from=${year}-01-01&to=${year}-12-31`);
  const data = parseContributions(html);
  const years = await getAvailableYears();
  const withYears = { ...data, year, years };
  cacheSet(`year:${year}`, data);
  return withYears;
}

async function getAvailableYears(): Promise<string[]> {
  const cached = cacheGet<string[]>("years");
  if (cached) return cached;

  const current = new Date().getFullYear();
  const candidates = Array.from({ length: 8 }, (_, i) => String(current - i));
  const settled = await Promise.allSettled(
    candidates.map(async (y) => {
      const hit = cacheGet<ContributionData>(`year:${y}`);
      if (hit) return hit;
      const html = await fetchPage(`?from=${y}-01-01&to=${y}-12-31`);
      const data = parseContributions(html);
      cacheSet(`year:${y}`, data);
      return data;
    })
  );

  const years: string[] = [];
  settled.forEach((res, i) => {
    if (res.status === "fulfilled" && res.value.total > 0) years.push(candidates[i]);
  });
  years.sort((a, b) => Number(b) - Number(a));

  cacheSet("years", years);
  return years;
}

function parseContributions(html: string) {
  const totalMatch = html.match(/class="f4 text-normal mb-2">\s*([\d,]+)\s+contributions?/);
  const total = totalMatch ? Number(totalMatch[1].replace(/,/g, "")) : 0;

  const rows = parseRows(html);
  const weeks = buildWeeks(rows);

  const all = weeks.flat().filter(Boolean) as ParsedDay[];
  const firstDay = all[0];
  const lastDay = all[all.length - 1];

  return {
    username,
    url: `https://github.com/${username}`,
    total,
    rows,
    weeks,
    range: {
      from: firstDay?.date ?? null,
      to: lastDay?.date ?? null,
    },
  };
}

function parseRows(html: string): ParsedRow[] {
  const tbody = html.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "";
  const rowsHtml = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)].map((m) => m[1]);

  return rowsHtml.map((rowHtml) => {
    const labelMatch = rowHtml.match(/ContributionCalendar-label[^>]*>[\s\S]*?<span[^>]*>([^<]+)<\/span>/);
    const label = labelMatch ? labelMatch[1] : "";

    const cells: (ParsedDay | null)[] = [];
    const tagRe = /<td\b[^>]*>/g;
    let m: RegExpExecArray | null;

    while ((m = tagRe.exec(rowHtml))) {
      const tag = m[0];
      if (tag.includes("ContributionCalendar-label")) continue;

      const dateMatch = tag.match(/data-date="([^"]+)"/);
      const levelMatch = tag.match(/data-level="([0-4])"/);
      if (!dateMatch || !levelMatch) {
        cells.push(null);
        continue;
      }

      const idMatch = tag.match(/id="(contribution-day-component-[^"]+)"/);
      const count = idMatch ? countFromTooltip(html, idMatch[1]) : 0;
      cells.push({ date: dateMatch[1], level: Number(levelMatch[1]), count });
    }

    return { label, cells };
  });
}

function countFromTooltip(html: string, cellId: string): number {
  const tooltipRe = new RegExp(
    `<tool-tip[^>]*for="${escapeRegex(cellId)}"[^>]*>([^<]*)</tool-tip>`
  );
  const m = tooltipRe.exec(html);
  if (!m) return 0;
  const text = m[1].trim();
  const countMatch = text.match(/(\d+)\s+contributions?/);
  return countMatch ? Number(countMatch[1]) : 0;
}

function escapeRegex(value: string) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function buildWeeks(rows: ParsedRow[]): (ParsedDay | null)[][] {
  const weekCount = Math.max(0, ...rows.map((r) => r.cells.length));
  const weeks: (ParsedDay | null)[][] = [];
  for (let w = 0; w < weekCount; w++) {
    weeks.push(rows.map((r) => r.cells[w] ?? null));
  }
  return weeks;
}

components/home/GitHubContributionChart.tsx

"use client";

import { useEffect, useState } from "react";

const USERNAME = "NischalAcharya060";

type Day = { date: string; level: number; count: number };
type Week = (Day | null)[];

type ChartData = {
  username: string;
  url: string;
  total: number;
  rows: { label: string; cells: (Day | null)[] }[];
  weeks: Week[];
  range: { from: string | null; to: string | null };
  year?: string;
  years?: string[];
};

const LEVEL_BG = [
  "color-mix(in srgb, var(--foreground) 7%, transparent)",
  "color-mix(in srgb, var(--accent) 25%, transparent)",
  "color-mix(in srgb, var(--accent) 45%, transparent)",
  "color-mix(in srgb, var(--accent) 65%, transparent)",
  "color-mix(in srgb, var(--accent) 90%, transparent)",
];

const MONTHS = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun",
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];

const ROW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

const LABEL_WIDTH = 30;
const CELL = 11;
const GAP = 3;

function dateFromISO(iso: string): Date {
  const [y, m, d] = iso.split("-").map(Number);
  return new Date(y, m - 1, d);
}

function shortMonth(iso: string): string {
  return MONTHS[dateFromISO(iso).getMonth()];
}

function formatCount(count: number, date: string) {
  const label = count === 1 ? "contribution" : "contributions";
  const p = dateFromISO(date);
  const day = p.getDate();
  const weekday = p.toLocaleDateString("en-US", { weekday: "long" });
  return `${count} ${label} on ${weekday}, ${MONTHS[p.getMonth()]} ${day}, ${p.getFullYear()}`;
}

function MonthLabels({ weeks }: { weeks: Week[] }) {
  type Group = { label: string; start: number; count: number };
  const groups: Group[] = [];
  weeks.forEach((week, index) => {
    const first = week.find(Boolean);
    if (!first) return;
    const label = shortMonth(first.date);
    const prev = groups[groups.length - 1];
    if (prev && prev.label === label) {
      prev.count++;
    } else {
      groups.push({ label, start: index, count: 1 });
    }
  });

  return (
    <div
      className="grid"
      style={{ gridTemplateColumns: `${LABEL_WIDTH}px repeat(${weeks.length}, ${CELL}px)`, gap: GAP, height: 16 }}
      aria-hidden="true"
    >
      <div className="col-start-1 row-start-1" />
      {groups.map((g, i) => (
        <span
          key={`${g.label}-${i}`}
          className="truncate text-[9px] leading-none text-muted-2"
          style={{ gridColumn: `${g.start + 2} / span ${g.count}`, gridRow: 1, alignSelf: "center" }}
        >
          {g.label}
        </span>
      ))}
    </div>
  );
}

function ContributionGrid({
  weeks,
  rows,
  label,
}: {
  weeks: Week[];
  rows: ChartData["rows"];
  label: string;
}) {
  return (
    <div
      className="grid w-max"
      style={{ gridTemplateColumns: `${LABEL_WIDTH}px repeat(${weeks.length}, ${CELL}px)`, gap: GAP }}
      role="img"
      aria-label={label}
    >
      {rows.map((row, d) => {
        const label = row.label || ROW_LABELS[d] || "";
        return (
          <div
            key={d}
            className="flex items-center justify-end pr-0 text-[8px] text-muted-2/80"
            style={{ gridColumn: 1, gridRow: d + 1, height: CELL }}
          >
            {label.length >= 3 ? label.slice(0, 3) : label}
          </div>
        );
      })}

      {weeks.map((week, w) =>
        week.map((day, d) => (
          <div
            key={`${w}-${d}`}
            title={day ? formatCount(day.count, day.date) : undefined}
            aria-label={day ? formatCount(day.count, day.date) : "no data"}
            className="rounded-[2px]"
            style={{
              gridColumn: w + 2,
              gridRow: d + 1,
              width: CELL,
              height: CELL,
              background: day ? (LEVEL_BG[day.level] ?? LEVEL_BG[0]) : "transparent",
              boxShadow: day
                ? "inset 0 0 0 1px color-mix(in srgb, var(--foreground) 8%, transparent)"
                : undefined,
            }}
          />
        ))
      )}
    </div>
  );
}

function Legend() {
  return (
    <div className="flex items-center gap-1.5 font-mono text-[9px] text-muted-2">
      <span>Less</span>
      {[0, 1, 2, 3, 4].map((l) => (
        <span
          key={l}
          aria-hidden="true"
          className="rounded-[2px]"
          style={{ width: 9, height: 9, background: LEVEL_BG[l], boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--foreground) 8%, transparent)" }}
        />
      ))}
      <span>More</span>
    </div>
  );
}

function Skeleton() {
  return (
    <div className="space-y-3" aria-hidden="true">
      <div className="flex items-center gap-2 px-0.5">
        <div className="h-2.5 w-24 animate-pulse rounded bg-surface-2" />
        <div className="h-2.5 w-16 animate-pulse rounded bg-surface-2" />
      </div>
      <div className="h-[110px] w-full animate-pulse rounded-lg bg-surface-2/60" />
    </div>
  );
}

export default function GitHubContributionChart() {
  const [data, setData] = useState<ChartData | null>(null);
  const [years, setYears] = useState<string[]>([]);
  const [selected, setSelected] = useState<string | null>(null);
  const [error, setError] = useState(false);

  useEffect(() => {
    const ctrl = new AbortController();
    const query = selected ? `?year=${selected}` : "";
    fetch(`/api/github-contributions${query}`, { signal: ctrl.signal })
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
      .then((json) => {
        if (json.weeks?.length) {
          setData(json);
          if (json.years?.length) setYears(json.years);
          setError(false);
        } else {
          setError(true);
        }
      })
      .catch((err: unknown) => {
        if (err instanceof DOMException && err.name === "AbortError") return;
        setError(true);
      });
    return () => ctrl.abort();
  }, [selected]);

  if (error) {
    return (
      <div className="rounded-xl border border-border bg-surface/40 px-5 py-4">
        <p className="flex flex-wrap items-center gap-1.5 font-mono text-xs text-muted-2">
          <span className="text-accent">›</span>
          Couldn’t load the contribution chart.{" "}
          <a
            href={`https://github.com/${USERNAME}`}
            target="_blank"
            rel="noopener noreferrer"
            className="text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
          >
            @{USERNAME}
          </a>
        </p>
      </div>
    );
  }

  if (!data) return <Skeleton />;

  const rangeLabel = selected
    ? selected
    : data.range.from && data.range.to
    ? `${shortMonth(data.range.from)} ${dateFromISO(data.range.from).getFullYear()} – ${shortMonth(data.range.to)} ${dateFromISO(data.range.to).getFullYear()}`
    : "last 12 months";

  const yearTabs = ["last", ...years];

  return (
    <div className="mb-10 rounded-xl border border-border bg-surface/40 p-5 sm:p-6">
      <div className="mb-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
        <div className="flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs">
          <span className="text-sm sm:text-base">
            <span className="font-semibold text-foreground tabular-nums">{data.total}</span>
            <span className="text-muted-2"> contributions</span>
          </span>
          <span className="hidden text-muted-2/60 sm:inline">·</span>
          <span className="text-[11px] text-muted-2">{rangeLabel}</span>
        </div>
        <a
          href={`https://github.com/${USERNAME}`}
          target="_blank"
          rel="noopener noreferrer"
          className="inline-flex items-center gap-1.5 font-mono text-[11px] text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
        >
          @{USERNAME} ↗
        </a>
      </div>

      {/* Year switcher */}
      {yearTabs.length > 1 && (
        <div
          role="tablist"
          aria-label="Contribution year"
          className="mb-4 inline-flex flex-wrap items-center gap-1 rounded-lg border border-border bg-background/50 p-1 font-mono text-xs"
        >
          {yearTabs.map((item) => {
            const isActive = selected === (item === "last" ? null : item);
            return (
              <button
                key={item}
                role="tab"
                aria-selected={isActive}
                onClick={() => setSelected(item === "last" ? null : item)}
                className={`rounded-md px-2.5 py-1 font-medium transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
                  isActive
                    ? "bg-accent text-black shadow-sm"
                    : "cursor-pointer text-muted hover:text-foreground"
                }`}
              >
                {item === "last" ? "last 12 months" : item}
              </button>
            );
          })}
        </div>
      )}

      <div className="custom-scrollbar overflow-x-auto pb-1">
        <MonthLabels weeks={data.weeks} />
        <div className="mt-1.5">
          <ContributionGrid
            weeks={data.weeks}
            rows={data.rows}
            label={selected ? `GitHub contribution graph for ${selected}` : "GitHub contribution graph for the last 12 months"}
          />
        </div>
      </div>

    </div>
  );
}