6,835 chess games, one dashboard
How I turned my chess.com history into an interactive dashboard: a build-time extract, one fact table at game grain, cross-filtering in the browser, and an opening explorer on a real board.
- Data engineering
- Data visualisation
I've played chess on chess.com since 2018, mostly blitz, mostly in the evening, and I've never looked at the data properly. The chess dashboard is the first entry in the Lab, a corner of this site for small projects built on my own data. It's a hobby project, but I built it the way I'd build a report at work: extract, model, then visualise. This post covers the tools and the decisions.
Extract: a build step, not an API call
chess.com has a public API that needs no key. Two endpoints cover everything:
/pub/player/{user}/games/archiveslists one URL per month with games in it.- Each monthly archive returns that month's games with result, ratings, time control, opening URL and the full PGN.
The page itself never calls chess.com. A Node script runs before every build (prebuild in
package.json), walks the monthly archives one at a time (the API asks clients not to fetch in parallel)
and writes a single JSON file that the page imports. Every deploy on Vercel gets fresh data, and a visitor
only ever downloads a static file.
Because the extract runs during the build, a broken extract could break the deploy. The script never throws: if chess.com is unreachable, it falls back to the last good copy kept in the build cache, and failing that the page shows an empty state.
Model: one fact table at game grain
My first version stored ready-made aggregates: games per month, score by hour and so on. It was small and simple, but every number was fixed. Once I wanted to click on things and drill down, I had to go back to the grain.
The file is now one row per game, stored as compact arrays with lookup lists for the repeated text (months, opening names, ways a game can end):
[month, weekday, hour, timeControl, colour, result, ending, opening, ratingGap, rating, previousResult, "e4 d5 exd5 Qxd5 Nc3 Qa5 ..."]
That's a fact table with its dimensions folded in. 6,835 games come to about 470 KB before compression, which is fine for a page like this.
What I left out mattered as much as what I kept. There are no game links, no opponent names and no timestamps. Time survives only as month, weekday and hour, which is enough for every chart and not enough to reconstruct when I was online.
Three things the raw data got wrong
Ratings are recorded after the game. My first "score against stronger and weaker opponents" chart
said I won 70% of games against slightly weaker players and 28% against slightly stronger ones. That is
a measurement artefact. A win raises my recorded rating and lowers theirs, so after the fact every win
looks like a game against a weaker player. The fix is to take my pre-game rating from my previous rated
game in the same time control, and the opponent's from the WhiteElo / BlackElo headers in the PGN.
After the fix, the curve looks like chess again.
Time zones move when you do. chess.com stores times in UTC. I lived in Brazil until 2023 and in Europe since, so a single time zone would put five years of Brazilian evenings in the European small hours. A small config file maps date ranges to time zones, and every game is converted with the zone that applied when it was played.
Gaps aren't flat lines. A month-end rating chart that carries the last value forward draws a calm horizontal line across a year in which I didn't play at all. Lines now break after three months without games. Small time controls with a few dozen games are left off the chart, because their lines are mostly noise.
Interact: cross-filtering without a BI tool
The dashboard behaves like a Power BI report page. A filter bar sets time control, colour and period, and clicking almost anything drills down: a heatmap cell, a weekday, a time-of-day bar, an opening, how a game ended. Each choice shows up as a removable chip.
The one rule that makes this feel right is borrowed from cross-filtering: each panel applies every filter except its own. Clicking "Tuesday" filters the heatmap, openings and ratings to Tuesdays, but the weekday chart still shows all seven days with Tuesday highlighted, so you can compare or switch. In code it is a single predicate with a list of keys to skip:
const sel = (...skip: Key[]) => games.filter((g) => pass(g, skip));
const clock = aggregate(sel("cell", "block", "weekday")); // the time charts ignore time filters
const openings = aggregate(sel("opening")); // the openings table ignores the opening filter
Re-aggregating 7,000 rows eight times on every click sounds wasteful, but it still feels instant. Precomputing every combination would be far more work for no visible gain.
The opening explorer
The best part is a chessboard. It starts at the initial position and lists the moves I actually played or faced from there, each with games, wins, draws, losses and score. Click a move to play it, hover to see an arrow on the board, and go deeper.
Underneath it's a prefix count: of the games that start with the current move sequence, group by the next move. The extract keeps the first ten moves of each game, parsed out of the PGN (clock comments and move numbers stripped). On the page, chess.js replays the moves to get the position, and the board is a plain SVG with Unicode pieces. One checkbox turns the current line into a filter for the whole dashboard, so "how do I do after 1.e4 d5" applies to every chart.
Charts that don't lie
A few rules I kept to:
- Scores are diverging bars around 50%. Most of my splits fall between 45% and 56%. Bars from zero would all look the same, and a truncated axis would exaggerate. Bars that grow left or right from an even score show the difference honestly.
- Colour follows the entity. Rapid is always blue and blitz always orange, whatever is filtered. I checked the palette for colour-blind separation instead of judging it by eye.
- Everything has a table view, and every mark has a tooltip that also works with the keyboard.
What the data says
Some findings, from all 6,835 games:
- My rapid rating is at its all-time high, even though 5,680 of my games are blitz.
- 48% of my games as black are the Scandinavian, and I score 59% with it.
- I play best after midnight (56%) and worst in the morning (48%).
- Tilt exists, but barely: 50% after a loss, 52% after a win.
Tools
Next.js and TypeScript, with the extract in Node and deploys on Vercel. The charts are hand-written SVG and CSS rather than a charting library, and chess.js replays the moves on the board. It's free to run and has no database.