Most of what follows is the version we would tell another developer over a beer. Not the marketing version. The product is a vintage astronomical print made from someone's GitHub history, but the interesting bits are mostly behind the canvas. This is what they were.
It started as a joke
The whole thing started as a throwaway idea: a contribution graph looks a bit like a sky. Make it actually look like a sky. Print it.
That was easy for a hundred commits. It was a different problem entirely once we tried it on Linus Torvalds, who has twenty years of history and more activity in a quiet month than most accounts have in a year. The renderer fell over. The fetch timed out. The page hung. The thing that had worked beautifully on our test profile turned out to be a toy. Most of the work since has been about making the toy survive contact with real GitHub accounts.
The GitHub API is two APIs in a trench coat
You cannot build anything serious on top of GitHub without learning this the hard way. There is REST, which is comprehensive and old and paginated everywhere. There is GraphQL, which is newer, faster for shaped queries, and the only sane way to get a contribution calendar without burning a thousand requests. The two are rate-limited differently. They disagree about timezones. They occasionally disagree about counts.
We use both. GraphQL for the calendar itself, because that is the one place where it is dramatically better. REST for repositories, stargazer events, and the metadata that GraphQL either does not expose or makes worse to query. The hybrid is uglier than picking a side, but it is the only thing that actually works.
The other thing you learn quickly is that the contribution calendar is not quite what it appears to be. It is a per-day count, normalised to a viewer's timezone, with a maximum window of about a year. To assemble a multi-year history, you walk it year by year. Some years come back instantly. Some years, on some accounts, do not. We retry. We back off. We log the ones that misbehave.
Sampling stargazers without lying
The contribution graph is bounded. Stargazers are not. A repository with four hundred thousand stars has four hundred thousand timestamped events, paginated a hundred at a time, with a media-type header you have to opt into to get the dates at all. Pulling all of it for every repo is not realistic. Pulling none of it makes the stars mode useless.
So we sample. Adaptively, based on star count: the threshold and stride scale so that a small repo gets every star and a viral repo gets a representative slice across its lifetime. The slice is large enough that the shape of the curve is right. It is not large enough to claim a count.
This is where the honesty rule comes in. The stats panel only shows numbers we can defend. If we sampled, we do not pretend we did not. We report what we actually measured and we leave the rest alone. No invented quantiles, no fake confidence intervals, no extrapolated totals. If we cannot stand behind the number, the number is not on the page.
Caching, and the day we learned about race conditions
GitHub's rate limits are not generous when you are doing this for every visitor to a marketing site. So we cache aggressively, in two layers.
The first layer is permanent. Once we have fetched the expensive parts of a profile or repository, we keep them. They do not change retroactively. Yesterday's stars are still yesterday's stars.
The second layer is a seven-day incremental refresh. When someone visits a cached profile, we serve the cache immediately and, in the background, fetch only what is new since the last refresh. The fresh data gets merged on top.
This worked beautifully for about a week, and then we discovered that two tabs opening the same profile at the same time would both kick off a background refresh, both write their merged result, and occasionally race each other in ways that produced a flickering partial=true flag and a banner that would not go away. The fix was small and slightly embarrassing: a thirty-second debounce on background refreshes per cache key. If we refreshed it in the last half minute, do not refresh it again. The flicker stopped that day.
The metadata for all of this lives in a single JSONB column on the cache row, called cache_metadata. It tracks last-fetched timestamps, the shape of what we have, and which stages of the fetch are complete. Most of the cleverness of the cache is in what that column stores. Everything else is bookkeeping.
Six layouts, one renderer
There are six layout formats: Chronicle, Planisphere, Helix, Rosette, Horizon, Astrolabe. Each one is a different way of projecting a year (or a slice of years) onto a flat surface. Chronicle is a column. Planisphere is a circular star chart in the tradition of a five-thousand-year-old instrument. Helix spirals. Rosette aggregates into weekly petals. Horizon stretches the timeline into a landscape band. Astrolabe is radial.
The first attempt had a different React component for each layout. By the third, we knew that was a mistake. They were drifting. A fix to one was not a fix to the others. The colour temperatures were slightly different. The star sizes were slightly different. It was untenable.
So we collapsed them. There is one canvas renderer. Each layout is, in effect, a projection function that maps a date and a count to an (x, y, radius, opacity). Everything else (the multi-layer glows in dark themes, the ink-on-paper texture in light themes, the magnitude legend, the density scaling) is shared. The renderer is large and somewhat dense, but it is one file we trust, not six files we keep in nervous sync.
Stars mode and the flatline problem
When you switch from commits to stars, you are no longer drawing daily counts. You are drawing a cumulative curve of events that arrive at irregular times. The naive plot is a step function with long flat stretches between bursts, which looks like nothing, and it looks especially like nothing on a landscape layout.
We interpolate. Linearly between known points on most layouts, with a monotone cubic Hermite spline on Horizon, where the curve is the entire visual. The spline is monotone deliberately: stars only go up, and a non-monotone interpolation will invent dips that did not happen. That would be lying with curves. We do not do that.
Themes are not just colours
There are four themes: Observatory, Parchment, Blueprint, Minimal. Two are dark. Two are light. Switching themes does more than change the palette. Dark themes get multi-layer radial glows around each star, with additive blending so dense regions bloom. Light themes get an ink-on-paper aesthetic with no glow at all, and the stars become small dense marks rather than soft points.
Themes also reach into the wall preview, because a dark print on a wall sits inside a dark mat with less padding, and a light print sits inside a cream mat with more. The ratios live in a shared file called wallComposite.ts. The live HTML preview and the exported PNG composite both read from the same constants, so a frame in the browser and a frame in the email look like the same object. That sounds obvious until you spend a week chasing a four-pixel difference between them.
The wall preview cannot fudge the scale
The wall preview is a render of the framed print hanging on a wall, in a room, in a particular lighting condition. It is a marketing image, not a physics simulation. But people use it to imagine the print on their own wall, and the moment it gets scale or proportion wrong, the trust goes.
So we auto-select the scene based on theme, format, and print size. A blueprint A1 lands in a different room from a parchment A3. The framed composite is horizontally centred on the chosen wall. The frame gradient is shared between the in-browser CSS and the canvas export, so the wood grain in the preview is the same wood grain in the downloadable mockup. There is a room URL parameter that overrides the auto-selection, mostly for when we are choosing scenes for the landing page and need to pin one deliberately.
Headless rendering for fulfilment
When someone buys a print, we need a real file. Not a screenshot. Not the on-screen canvas. A print-ready PNG at the resolution the printer expects, deterministically, every time, regardless of who triggered the render or what device they were on.
The way this works is mildly ridiculous and we are very proud of it. We have a route called /render-print that, when given a fully serialised set of parameters, renders the exact same canvas the customer saw, but at the printer's native resolution. A headless browser hits that route, waits for the canvas to finish drawing, screenshots it, and ships the PNG back. The print pipeline then uploads that PNG to fulfilment.
The route has one hard requirement: it must work in cold contexts where the database might be slow or unreachable, because a printer is waiting on the other end. So it depends on as little as possible. The resolution presets are baked in as a synchronous constant called FALLBACK_RESOLUTIONS, which means the render never blocks on a network read for something we already know.
The postcard generator is a smaller cousin of the same idea: an HTML page rendered to PNG by the same headless setup, with a different template. Once you have the pattern, you reuse it.
Stripe, webhooks, and the basket that became a fan-out
The basket started simple: one print, one checkout. Then people wanted to buy two of different sizes, or a print and a digital download together, or the same print for three friends with three different gift messages. The basket became a list of line items, and the line items had to fan out into separate orders, because fulfilment partners think in single shipments and gift messages do not merge.
So a checkout session is one Stripe object, but the webhook on the other end creates one orders row per line, kicks off one process-order invocation per row, and lets the Prodigi side think about each one independently. If one line is a digital download, it skips Prodigi entirely and goes to the signed-URL email flow.
The orders table is locked down hard. Row level security is on, there are no policies that grant the client any access, and every read and write goes through edge functions running with the service role. The client has no path to it at all. This is the kind of decision that feels paranoid right up until the moment someone asks "wait, can a logged-in user see other people's orders?" and the answer is a flat "no, there is no path".
Edge function gotchas we paid for in production
A few small things that cost us evenings.
Heavy dependencies in edge functions must be imported with the npm: prefix, not as bare URL imports. The wrong import shape will silently inflate the bundle and the function will time out during deployment with a message that does not obviously point at the cause.
Public functions that do not need auth (fetch-github, resolve-org, the sitemap, the webhook handlers) need verify_jwt = false in config.toml. Forgetting that turns a one-line bug into a half-day mystery.
The render route especially must not depend on any async setup it does not absolutely need. The headless browser has a timeout. The printer has a queue. If the function takes too long to first paint, the render can die before the order moves forward. That is the failure mode the whole render path is built to avoid, which is why the synchronous fallback for resolution presets matters: in the render path, do less.
Dates are not a solved problem
We have a single helper file for date arithmetic. Every parse goes through parseLocalDate. Every format goes through formatLocalDate. They are both local-timezone aware, on purpose. If you let dates drift through new Date(string) and toISOString() and the various Date constructors, you will, eventually, draw a contribution from Sunday on Saturday because someone in Auckland visited the site at midnight UTC. We have made this mistake. We will not make it again.
The small details that nobody asked for
The structural labels on the atlases (Mensis for the months, Hebdomas for the weeks, Catalogus for the legend) are hardcoded Latin. They do not translate. They are a design decision, not an internationalisation bug.
The body text uses single hyphens, never em dashes. Em dashes feel modern in a way that does not belong on something pretending to be a sixteenth-century chart.
There are triple-rule borders around almost every section, because a single rule looks like a div and three rules look like an engraving.
What we would do differently
The cache layer should have been a Postgres function from day one. Doing it in TypeScript across an edge function meant we re-implemented locking and debouncing badly before we got it right.
The renderer should have been canvas from the start. We tried SVG first. It was beautiful for a thousand stars and unusable for a hundred thousand.
The stats panel went through three iterations before we accepted that the only honest answer was to show fewer numbers. There is a version of the product where we faked the missing ones. We are glad we did not ship it.
But on the whole, we are happy. The thing prints. The thing arrives. The thing looks like a star map of someone's working life, which is what we said it would be when we started, slightly as a joke.