Risan Bagja

tutorial··33 min read

Building a GitHub Activity Heatmap

A tour of the GraphQL fetch, daily JSON snapshot, and Astro SVG behind the little grid on my homepage.

GitHub’s contribution graph is a tiny productivity dashboard disguised as confetti. I wanted one on my homepage, in the site’s colors, without a widget making another request whenever somebody visited.

The setup is pretty small: a Node script asks GitHub for the calendar, an Action refreshes a JSON snapshot every day, and an Astro component turns that snapshot into SVG. The browser doesn’t ask GitHub for anything. It gets the finished grid, plus one tiny script that moves the horizontal scroller to the latest week.

Get the calendar from GitHub

The GraphQL API already returns contributions grouped by week and day. Each day comes with a date, a count, a weekday number, and an intensity level. That’s most of what the drawing code needs:

Terminal window
query GetCalendar {
viewer {
login
calendar: contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
date
contributionCount
contributionLevel
weekday
}
}
}
contributionYears
}
}
}

viewer means the account behind the token. contributionLevel is NONE or one of four contribution quartiles, and weekday tells me which row each day belongs in. The full fetcher uses a longer query that also asks for this month’s and this year’s totals for the little stats cards. GitHub counts several kinds of activity here, including issues, pull requests, and commits, so the calendar is not a commit counter. The ContributionsCollection fields have the full list.

The fetch script looks for a token in the environment first. When I’m running it on my machine, it can also use the GitHub CLI login:

Terminal window
import { execSync } from 'node:child_process';
function resolveToken() {
if (process.env.GITHUB_TOKEN?.trim()) {
return process.env.GITHUB_TOKEN.trim();
}
if (process.env.GH_TOKEN?.trim()) {
return process.env.GH_TOKEN.trim();
}
try {
const out = execSync('gh auth token', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
if (out?.trim()) return out.trim();
} catch {
// The GitHub CLI isn't installed or logged in.
}
return null;
}

For private and internal activity, GitHub requires the optional read:user scope on the token. The script uses viewer, so it also checks that the token belongs to the expected account before writing anything. It refuses to replace the saved data with an empty calendar, too. That way a wrong token or a bad response doesn’t quietly turn the homepage into a very convincing week off.

Add the older years without a request per year

The calendar query includes contributionYears. I use GraphQL aliases to ask for every year’s totals in one more request, instead of making a separate round trip for each year:

Terminal window
const yearAliases = years
.map(
(year) =>
`y${year}: contributionsCollection(from: "${year}-01-01T00:00:00Z", to: "${year}-12-31T23:59:59Z") {
contributionCalendar { totalContributions }
totalCommitContributions
restrictedContributionsCount
}`,
)
.join('\n');
const queryAllYears = `query { viewer { ${yearAliases} } }`;
const dataAllYears = await graphql(token, queryAllYears);

Each y2025, y2024, and so on is an alias in the response. The script adds those results together for the all-time card. It then writes the calendar and totals to src/data/github-contributions.json. The snapshot contains counts, dates, weekdays, and intensity levels; the token stays in the environment and never goes into the page.

Keep the snapshot fresh

A GitHub Actions workflow runs the script at 02:00 UTC each day. I can also start it manually with workflow_dispatch, which is handy when testing and less handy when I forget what time zone I’m in. In .github/workflows/update-contributions.yml, the schedule and permission bits look like this:

Terminal window
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
permissions:
contents: write

The workflow passes a repository secret named GH_PAT to the script. That token needs permission to read the account’s contribution data and push the updated JSON file. After fetching and setting the bot’s Git identity, the workflow checks whether the snapshot actually changed before it makes a commit:

Terminal window
if git diff --quiet src/data/github-contributions.json; then
echo "No changes in contribution data."
else
git add src/data/github-contributions.json
git commit -m "chore(data): update github contributions"
git push origin main
fi

No new activity means no empty commit. On my laptop, the script can use the checked-in snapshot if I don’t have a token handy; in CI, a missing token is an error.

Draw the grid with Astro and SVG

The Astro component imports the JSON file directly:

Terminal window
---
import githubData from '../data/github-contributions.json';
const { stats, calendar, username } = githubData;
const weeks = calendar.weeks;
---

Astro reads that file while building the site. There is no browser request to GitHub and no chart library to download. The component loops over the weeks and draws one SVG rectangle for each day.

I used 10-pixel squares with a 3-pixel gap. weeks.length comes from GitHub, so the width adapts to the calendar instead of assuming it will always be exactly 53 weeks:

Terminal window
const CELL_SIZE = 10;
const CELL_GAP = 3;
const STEP = CELL_SIZE + CELL_GAP;
const X_OFFSET = 30; // room for weekday labels
const Y_OFFSET = 20; // room for month labels
const totalWeeks = weeks.length;
const svgWidth = X_OFFSET + totalWeeks * STEP;
const svgHeight = Y_OFFSET + 7 * STEP;

A day’s weekday gives its row; the week index gives its column. GitHub’s contribution level maps to one of five CSS classes, from empty to busiest:

Terminal window
const levelClassMap: Record<string, string> = {
NONE: 'gh-cell-0',
FIRST_QUARTILE: 'gh-cell-1',
SECOND_QUARTILE: 'gh-cell-2',
THIRD_QUARTILE: 'gh-cell-3',
FOURTH_QUARTILE: 'gh-cell-4',
};

Then the nested loop does the repetitive part. The <title> gives each square a date and count when you hover it:

Terminal window
<svg
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
class="heatmap-svg"
role="img"
aria-label={`GitHub contribution heatmap for @${username}`}
>
{
weeks.map((week, wIndex) => {
const x = X_OFFSET + wIndex * STEP;
return (
<g class="heatmap-week">
{week.contributionDays.map((day) => {
const y = Y_OFFSET + day.weekday * STEP;
const levelClass = levelClassMap[day.contributionLevel] || 'gh-cell-0';
const date = new Date(day.date).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
const title = `${day.contributionCount} contribution${day.contributionCount === 1 ? '' : 's'} on ${date}`;
return (
<rect
x={x}
y={y}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
ry={2}
class={`gh-cell ${levelClass}`}
data-date={day.date}
data-count={day.contributionCount}
>
<title>{title}</title>
</rect>
);
})}
</g>
);
})
}
</svg>

I also print month names across the top and just Monday, Wednesday, and Friday down the side. Seven weekday labels made the gutter noisy at this size. The SVG sits in a horizontally scrollable container on small screens, and a tiny inline script moves that container to the newest week on load.

Give the levels their own colors

The data says how busy a day was; CSS decides what that looks like. I used a muted terracotta ramp for the light theme and a brighter version for dark mode:

Terminal window
:root {
--gh-level-0: #ede8de;
--gh-level-1: #f4dcd3;
--gh-level-2: #e39c84;
--gh-level-3: #c8502e;
--gh-level-4: #872e15;
}
:global(html.dark) {
--gh-level-0: #222428;
--gh-level-1: #3d231b;
--gh-level-2: #733725;
--gh-level-3: #c05435;
--gh-level-4: #e06b47;
}
.gh-cell-0 { fill: var(--gh-level-0); }
.gh-cell-1 { fill: var(--gh-level-1); }
.gh-cell-2 { fill: var(--gh-level-2); }
.gh-cell-3 { fill: var(--gh-level-3); }
.gh-cell-4 { fill: var(--gh-level-4); }

The colors can change with the theme without fetching data again or rebuilding the grid in JavaScript.

Put it on the homepage

With the component ready, adding it to the page was just an import and a tag. I placed it after the intro and before the post lists:

Terminal window
---
import GithubHeatmap from '../components/GithubHeatmap.astro';
---
<GithubHeatmap />

That’s the whole pipeline: GitHub data, a saved JSON file, then SVG. Here’s a rendered example on this post; the homepage version reads from the refreshed snapshot.

The result

Vanity Signals

5,729 contributions in the last year

@risan ↗
This Month 1,016 124 public · 892 private
This Year 5,095 650 public · 4,445 private
All Time 20,070 4,120 public · 15,950 private
SepOctNovDecJanFebMarAprMayJunJulAugSep Mon Wed Fri 0 contributions on 21 Sept 20253 contributions on 22 Sept 20252 contributions on 23 Sept 20253 contributions on 24 Sept 20251 contribution on 25 Sept 20257 contributions on 26 Sept 20257 contributions on 27 Sept 20250 contributions on 28 Sept 202521 contributions on 29 Sept 202518 contributions on 30 Sept 202525 contributions on 1 Oct 20252 contributions on 2 Oct 202512 contributions on 3 Oct 20257 contributions on 4 Oct 20250 contributions on 5 Oct 20253 contributions on 6 Oct 202514 contributions on 7 Oct 20250 contributions on 8 Oct 20254 contributions on 9 Oct 20255 contributions on 10 Oct 20250 contributions on 11 Oct 20250 contributions on 12 Oct 20250 contributions on 13 Oct 202519 contributions on 14 Oct 20252 contributions on 15 Oct 20254 contributions on 16 Oct 202515 contributions on 17 Oct 20250 contributions on 18 Oct 20250 contributions on 19 Oct 202526 contributions on 20 Oct 20255 contributions on 21 Oct 202513 contributions on 22 Oct 20250 contributions on 23 Oct 20252 contributions on 24 Oct 20253 contributions on 25 Oct 20250 contributions on 26 Oct 202515 contributions on 27 Oct 202513 contributions on 28 Oct 20250 contributions on 29 Oct 20257 contributions on 30 Oct 20251 contribution on 31 Oct 20255 contributions on 1 Nov 20250 contributions on 2 Nov 20258 contributions on 3 Nov 20251 contribution on 4 Nov 202510 contributions on 5 Nov 20256 contributions on 6 Nov 20252 contributions on 7 Nov 20251 contribution on 8 Nov 20250 contributions on 9 Nov 202510 contributions on 10 Nov 20255 contributions on 11 Nov 20252 contributions on 12 Nov 202518 contributions on 13 Nov 202510 contributions on 14 Nov 20250 contributions on 15 Nov 20250 contributions on 16 Nov 20251 contribution on 17 Nov 20258 contributions on 18 Nov 20252 contributions on 19 Nov 202513 contributions on 20 Nov 20259 contributions on 21 Nov 20250 contributions on 22 Nov 20250 contributions on 23 Nov 20258 contributions on 24 Nov 202524 contributions on 25 Nov 202528 contributions on 26 Nov 20254 contributions on 27 Nov 20251 contribution on 28 Nov 20251 contribution on 29 Nov 20255 contributions on 30 Nov 20255 contributions on 1 Dec 20255 contributions on 2 Dec 202515 contributions on 3 Dec 20259 contributions on 4 Dec 202519 contributions on 5 Dec 20250 contributions on 6 Dec 20250 contributions on 7 Dec 20258 contributions on 8 Dec 202520 contributions on 9 Dec 20250 contributions on 10 Dec 202515 contributions on 11 Dec 20252 contributions on 12 Dec 20259 contributions on 13 Dec 20250 contributions on 14 Dec 202514 contributions on 15 Dec 20252 contributions on 16 Dec 20255 contributions on 17 Dec 20255 contributions on 18 Dec 20252 contributions on 19 Dec 20253 contributions on 20 Dec 20250 contributions on 21 Dec 20256 contributions on 22 Dec 20255 contributions on 23 Dec 20252 contributions on 24 Dec 20252 contributions on 25 Dec 202513 contributions on 26 Dec 20250 contributions on 27 Dec 20250 contributions on 28 Dec 202511 contributions on 29 Dec 20256 contributions on 30 Dec 202510 contributions on 31 Dec 20252 contributions on 1 Jan 202625 contributions on 2 Jan 202613 contributions on 3 Jan 20260 contributions on 4 Jan 20269 contributions on 5 Jan 20265 contributions on 6 Jan 20266 contributions on 7 Jan 202619 contributions on 8 Jan 202618 contributions on 9 Jan 202634 contributions on 10 Jan 20260 contributions on 11 Jan 20260 contributions on 12 Jan 20260 contributions on 13 Jan 20268 contributions on 14 Jan 20266 contributions on 15 Jan 202638 contributions on 16 Jan 20265 contributions on 17 Jan 202624 contributions on 18 Jan 202610 contributions on 19 Jan 202619 contributions on 20 Jan 202612 contributions on 21 Jan 202616 contributions on 22 Jan 20268 contributions on 23 Jan 20267 contributions on 24 Jan 20260 contributions on 25 Jan 20262 contributions on 26 Jan 20261 contribution on 27 Jan 20263 contributions on 28 Jan 202617 contributions on 29 Jan 202611 contributions on 30 Jan 202622 contributions on 31 Jan 20262 contributions on 1 Feb 202618 contributions on 2 Feb 20262 contributions on 3 Feb 202623 contributions on 4 Feb 202619 contributions on 5 Feb 202623 contributions on 6 Feb 20269 contributions on 7 Feb 20260 contributions on 8 Feb 20261 contribution on 9 Feb 202613 contributions on 10 Feb 202622 contributions on 11 Feb 202611 contributions on 12 Feb 20264 contributions on 13 Feb 20261 contribution on 14 Feb 20260 contributions on 15 Feb 20264 contributions on 16 Feb 202629 contributions on 17 Feb 202633 contributions on 18 Feb 202639 contributions on 19 Feb 20267 contributions on 20 Feb 202628 contributions on 21 Feb 20260 contributions on 22 Feb 20261 contribution on 23 Feb 202613 contributions on 24 Feb 20260 contributions on 25 Feb 20265 contributions on 26 Feb 20267 contributions on 27 Feb 20264 contributions on 28 Feb 20260 contributions on 1 Mar 20263 contributions on 2 Mar 20264 contributions on 3 Mar 202611 contributions on 4 Mar 202623 contributions on 5 Mar 202621 contributions on 6 Mar 202618 contributions on 7 Mar 20266 contributions on 8 Mar 20260 contributions on 9 Mar 202623 contributions on 10 Mar 202620 contributions on 11 Mar 20262 contributions on 12 Mar 20266 contributions on 13 Mar 20266 contributions on 14 Mar 20264 contributions on 15 Mar 202612 contributions on 16 Mar 20267 contributions on 17 Mar 20267 contributions on 18 Mar 202614 contributions on 19 Mar 202615 contributions on 20 Mar 20267 contributions on 21 Mar 20260 contributions on 22 Mar 20260 contributions on 23 Mar 20267 contributions on 24 Mar 202611 contributions on 25 Mar 202611 contributions on 26 Mar 20266 contributions on 27 Mar 20264 contributions on 28 Mar 20260 contributions on 29 Mar 20261 contribution on 30 Mar 202610 contributions on 31 Mar 202613 contributions on 1 Apr 202622 contributions on 2 Apr 202615 contributions on 3 Apr 20260 contributions on 4 Apr 20260 contributions on 5 Apr 20260 contributions on 6 Apr 20260 contributions on 7 Apr 202613 contributions on 8 Apr 20262 contributions on 9 Apr 20264 contributions on 10 Apr 20260 contributions on 11 Apr 20260 contributions on 12 Apr 20260 contributions on 13 Apr 202612 contributions on 14 Apr 202614 contributions on 15 Apr 20269 contributions on 16 Apr 20264 contributions on 17 Apr 20263 contributions on 18 Apr 20260 contributions on 19 Apr 20261 contribution on 20 Apr 2026105 contributions on 21 Apr 202632 contributions on 22 Apr 20269 contributions on 23 Apr 20263 contributions on 24 Apr 20269 contributions on 25 Apr 20263 contributions on 26 Apr 20263 contributions on 27 Apr 20267 contributions on 28 Apr 202627 contributions on 29 Apr 20261 contribution on 30 Apr 20263 contributions on 1 May 20262 contributions on 2 May 20260 contributions on 3 May 20266 contributions on 4 May 202617 contributions on 5 May 20264 contributions on 6 May 20267 contributions on 7 May 202621 contributions on 8 May 20260 contributions on 9 May 20260 contributions on 10 May 20264 contributions on 11 May 202617 contributions on 12 May 202612 contributions on 13 May 202613 contributions on 14 May 202626 contributions on 15 May 202616 contributions on 16 May 202626 contributions on 17 May 202628 contributions on 18 May 202627 contributions on 19 May 202621 contributions on 20 May 202639 contributions on 21 May 202619 contributions on 22 May 202613 contributions on 23 May 20267 contributions on 24 May 20268 contributions on 25 May 202622 contributions on 26 May 202629 contributions on 27 May 202611 contributions on 28 May 202619 contributions on 29 May 20266 contributions on 30 May 20260 contributions on 31 May 20263 contributions on 1 Jun 20267 contributions on 2 Jun 20260 contributions on 3 Jun 20262 contributions on 4 Jun 20262 contributions on 5 Jun 20260 contributions on 6 Jun 20260 contributions on 7 Jun 202615 contributions on 8 Jun 202617 contributions on 9 Jun 202611 contributions on 10 Jun 202617 contributions on 11 Jun 20267 contributions on 12 Jun 20267 contributions on 13 Jun 20260 contributions on 14 Jun 20264 contributions on 15 Jun 202630 contributions on 16 Jun 202628 contributions on 17 Jun 202630 contributions on 18 Jun 2026135 contributions on 19 Jun 202614 contributions on 20 Jun 202612 contributions on 21 Jun 20261 contribution on 22 Jun 20264 contributions on 23 Jun 20261 contribution on 24 Jun 20262 contributions on 25 Jun 20260 contributions on 26 Jun 20260 contributions on 27 Jun 20260 contributions on 28 Jun 20262 contributions on 29 Jun 202616 contributions on 30 Jun 202669 contributions on 1 Jul 202651 contributions on 2 Jul 202668 contributions on 3 Jul 202655 contributions on 4 Jul 20264 contributions on 5 Jul 202631 contributions on 6 Jul 202691 contributions on 7 Jul 202630 contributions on 8 Jul 20266 contributions on 9 Jul 202613 contributions on 10 Jul 202632 contributions on 11 Jul 202649 contributions on 12 Jul 202648 contributions on 13 Jul 202628 contributions on 14 Jul 202614 contributions on 15 Jul 20268 contributions on 16 Jul 202611 contributions on 17 Jul 202628 contributions on 18 Jul 202657 contributions on 19 Jul 202617 contributions on 20 Jul 202624 contributions on 21 Jul 202629 contributions on 22 Jul 202644 contributions on 23 Jul 202614 contributions on 24 Jul 20260 contributions on 25 Jul 20264 contributions on 26 Jul 202613 contributions on 27 Jul 202639 contributions on 28 Jul 202645 contributions on 29 Jul 202631 contributions on 30 Jul 202642 contributions on 31 Jul 202653 contributions on 1 Aug 202615 contributions on 2 Aug 202623 contributions on 3 Aug 202638 contributions on 4 Aug 202629 contributions on 5 Aug 202625 contributions on 6 Aug 202688 contributions on 7 Aug 202628 contributions on 8 Aug 20264 contributions on 9 Aug 202658 contributions on 10 Aug 202661 contributions on 11 Aug 202648 contributions on 12 Aug 202629 contributions on 13 Aug 202629 contributions on 14 Aug 20268 contributions on 15 Aug 202613 contributions on 16 Aug 202630 contributions on 17 Aug 202631 contributions on 18 Aug 202673 contributions on 19 Aug 202630 contributions on 20 Aug 202627 contributions on 21 Aug 20268 contributions on 22 Aug 202631 contributions on 23 Aug 202648 contributions on 24 Aug 202640 contributions on 25 Aug 202669 contributions on 26 Aug 202671 contributions on 27 Aug 202635 contributions on 28 Aug 202610 contributions on 29 Aug 20260 contributions on 30 Aug 202614 contributions on 31 Aug 202635 contributions on 1 Sept 202627 contributions on 2 Sept 202658 contributions on 3 Sept 202662 contributions on 4 Sept 202671 contributions on 5 Sept 202645 contributions on 6 Sept 202648 contributions on 7 Sept 202675 contributions on 8 Sept 202622 contributions on 9 Sept 202631 contributions on 10 Sept 202646 contributions on 11 Sept 202619 contributions on 12 Sept 202632 contributions on 13 Sept 202633 contributions on 14 Sept 202617 contributions on 15 Sept 202684 contributions on 16 Sept 202649 contributions on 17 Sept 202627 contributions on 18 Sept 20268 contributions on 19 Sept 202625 contributions on 20 Sept 2026100 contributions on 21 Sept 202698 contributions on 22 Sept 20264 contributions on 23 Sept 2026
Public commits and private client activity. None of this measures code quality.
Less More

The complete heatmap-only implementation—including the fetch script, workflow, Astro component, and homepage insertion—is in this GitHub commit. The grid is several hundred little rectangles. The interesting part was making sure they had something current to say.