How this is built
Three deployables, four data stores and eleven upstream services. This page is the map of what runs where, what holds which data, and what every endpoint is for.
Where a request goes
The two sites are static files. Everything that needs a key, a database or a verified identity goes through the API.
Everything that needs a key, a database or an identity goes through the API: three of the upstreams need a credential, and a key in a page is a key anyone can read with View Source. The one thing that does not is the basemap. Map tiles are fetched one per square as you pan, so your browser asks OpenFreeMap for them directly. Forwarding a few hundred of them a session would mean running a tile server. Satellite on /f1/ is a second host on the same terms, EOX's Sentinel-2 composite, and it is reached only after the Satellite control is turned on. Those two are the only upstreams your browser contacts itself, so the diagram names them as the exception: their arrows would have to leave from the top of the picture, and every other arrow leaves from the bottom. The upstream list below says what that costs.
The three deployables
Two of them are built from the same repository by two Eleventy configurations that share one set of includes and one stylesheet, so both draw their chrome from the same implementation.
_site/, served by GitHub Pages. Loads no Firebase at all.
static
_app/. The pages are static; what they show
comes from the API, which is what checks who you are.
static
Where data lives
Four places, and they hold very different things. The weather, the departures and the Formula 1 data are not stored anywhere.
-
Firebase Authentication
Email addresses and password hashes. The browser trades a password for a signed token, and the API only ever sees the token. Deleting an account here is what actually stops somebody signing in; everything else is a record about them.
-
Firestore
One document per account: name, role, approval state, home location, home stop, favourite teams. The role is read from here on every single request. It is a separate store from the one above, which is why an account can exist with no document and why erasure removes both.
-
Postgres
Ten tables. Contact submissions, a row per process start, which migrations have run, and the one-row table holding the secret behind the visitor number below. Five the system writes for itself: the status page's health checks (seven days), the shape of each upstream's answers and what changed in it (field names and types, never a value), GitHub webhook deliveries that passed their signature check (30 days), and the sports data, saved so a restart does not fetch three leagues again. And one row per page view: path, which of the two properties, the referring host, the time, a coarse browser, OS, device class and width bracket, the country as two letters (looked up on the server from the address; IP geolocation by DB-IP, CC BY 4.0), and a visitor number that is a 64-bit hash of a 24-hour secret, the address, the User-Agent and the property. The server feeds the address and the User-Agent into that hash and stores neither. It cuts the referring URL down to its host, sets no cookie, and erases the number from the row after 30 days. The secret is replaced every 24 hours and the old one is destroyed rather than kept, which is what stops one day's numbers being matched to the next day's; it is the one thing in this database the data view will not show you. Browsable on the data view if you are an admin. A contact submission can be deleted one row at a time from Messages, or all of one person's at once by erasure.
-
Memory
Every upstream answer is cached in the API process for between 45 seconds and six hours, and lost on restart, except the sports data, which is also saved to Postgres. A bus or a race lives in memory and nowhere else, so there is no history of either to browse.
Until recently nothing in this system could take a person out of any of them. It can now: erasure takes one email address and removes that address's contact messages from Postgres, its account document from Firestore and its sign-in from Firebase Authentication, then answers with a receipt of what it actually removed instead of a success message. One action across three stores, because that is how far a person is spread: deleting from one leaves the other two still holding them.
There is a fifth place, and it is the honest part of this page. A message sent through the contact form is stored and emailed, so a copy sits in a mailbox and in the mail provider's delivery log. Neither is a store this code owns, and nothing in the endpoint list below reaches into them. The erasure preview and the receipt both say so, in the API's own words, so nothing reports a deletion wider than the one that happened.
Why the two sites count differently
The public site counts only after you say yes. The banner is opt-in, declining means nothing is counted at all, and that has not changed. What changed is only where the count goes: to the API above, not to a third party.
This app counts every page load, with no banner. It is behind a login, everyone here is an approved account holder, and counting loads of your own service for the running of that service is operational telemetry, not audience measurement. A row here carries the same visitor number the public site's rows carry, and on the same terms. The secret behind it is replaced every 24 hours, and the number is erased from the row after 30 days. The legal basis is different, so the consent is too. The line that keeps that honest is being told, which is what this paragraph and the privacy notice are for; a count nobody is told about would be the same count with the honesty taken out.
"Everyone here is a known account holder" has to be true of the code, not only of the intent,
and for a while it was not: these pages are static files that anyone can fetch, and the
sign-in is a redirect JavaScript performs once one has loaded, so a stranger opening
/transit/ was counted on the way to being bounced. The beacon now fires from
shell.js only after /me has confirmed the session, which is the
earliest moment that sentence means anything. Not signed in, not counted.
Erasure does not reach this table, and an email address is not a handle it could be pointed at. A view row carries no account and no address. It does carry the number, which groups one browser's page loads inside a single day and stops there, because the next day's secret is a new one and the old one is gone. After 30 days the server erases the number and leaves the row as a path, a time and four coarse browser facts.
The endpoints
Every route the API serves, and the lowest role that may call it, grouped and folded away on a narrow screen. Each one re-reads your role and approval from Firestore before doing anything, because a token proves who signed in, not what they may do now.
Loading the endpoint list…
The upstreams, in detail
Ten of the eleven are called by the server. Three of those need a credential, which has to stay on the server; the rest so that one call serves every reader, and for the three sports sources, so a page reads a saved copy rather than three unofficial sites. The eleventh is the basemap, and it is not proxied at all: your browser fetches its tiles itself, which makes it the only one your browser has ever heard of. ESPN, shl.se and hockeyallsvenskan.se share one entry below, because they do one job, and so do the two papers. Each one below lists what is called, how long an answer is held, and how their field names become ours.
In the mapping tables, theirs is on the left and ours on the right. A row marked derived is something nobody sent: it is worked out here, and those rows are the decisions, not the transport.
Open-Meteo no key held 10 minutes, a day for place lookups
Forecast, air quality, and turning a place name into a coordinate.
What is called
/v1/forecast
current, hourly and five days, timezone=auto
/v1/air-quality
European AQI and PM2.5, on a separate host
/v1/search
place name to coordinate, held a day. Uppsala does not move
How it is mapped
Worth knowing
A forecast that is ten minutes old is the same forecast. The window exists to keep one open tab from calling a free service every time somebody switches back to it.
- Air quality is fetched alongside the forecast and allowed to fail on its own. Losing a forecast because a different service is having a bad morning would be a poor trade.
- An AQI is only attached when it arrives as a number. A null from upstream would otherwise be drawn as a band on the scale.
Trafiklab ResRobot v2.1 key held 45 seconds
Swedish departures, arrivals, stop search and stops near a point.
What is called
/v2.1/departureBoard
the board for one stop id
/v2.1/arrivalBoard
same shape, opposite direction
/v2.1/location.name
stop search by name
/v2.1/location.nearbystops
stops within 1.5 km of a coordinate
How it is mapped
Worth knowing
A departure board is wrong the moment it is stale, so this is the shortest window here. Departures and arrivals for the same stop are cached apart, because they are two answers.
- This one needs a credential, which is what keeps it on the server. That the key travels as a query parameter rather than a header makes it worse than usual: it would sit in the URL of every request a tab made, and a URL is the part everything keeps: the vendor’s own access logs, the browser’s history, any screenshot of a network tab. A header is not logged by default; a query string is.
- Times arrive as Swedish wall-clock with no offset. The container runs in UTC, so parsing them directly put every departure two hours out. They are resolved against Europe/Stockholm instead, which is tested across both daylight-saving changeovers.
- The board's `direction` is documented as the last stop of the trip, so it is a stop name rather than the headsign on the front of the bus. UL's own app shows something different because that is UL's headsign, which this feed does not carry.
Trafiklab GTFS Regional (UL) key held 15 seconds for positions, a week for the timetable
Where UL's buses are right now, and which line each one is running.
What is called
/gtfs-rt/ul/VehiclePositions.pb
every UL vehicle's position, as protobuf
/gtfs/ul/ul.zip
the timetable; routes.txt, trips.txt, stop_times.txt and stops.txt are read
How it is mapped
Worth knowing
A position is only worth having fresh, and the realtime key allows about 30,000 calls a month, so the server asks at most every 15 seconds, only while somebody has the map open, and stops at 950 a day. The timetable changes with the season and its key allows 50 downloads a month, so it is fetched weekly and kept in the database.
- Two keys, both kept on the server. Like ResRobot's they travel as a query parameter, so no URL that carries one is ever logged.
- UL leaves trip_headsign empty, so a bus's destination is the name of the last stop on its trip, by stop_sequence.
- A vehicle with no position, at 0,0, or not heard from for five minutes is left out: a parked bus is not a live one.
- The box and the lines your map asks about answer that one request and are not logged or stored.
Jolpica no key held 6 hours for the calendar, 5 minutes for results
Formula 1 calendar, championship standings and race results. The maintained successor to Ergast.
What is called
/{season}/races/
every round and its session times
/{season}/driverStandings/
and constructorStandings
/{season}/{round}/results/
plus qualifying and sprint, each allowed to be absent
How it is mapped
Worth knowing
Two clocks, so two windows. The calendar changes a few times a year; results change the moment a session ends, and this has no way of knowing when that was.
- Session times arrive as real UTC instants, which is the whole reason this one is pleasant and departures were not. Nothing here converts to a local zone: the instant is sent as ISO and the browser renders it wherever the reader is.
- A weekend stays the current weekend for three hours after its last session starts. The feed gives start times and no durations, so that number is an estimate and is named as one in the code.
- Results are read from whichever of the three tables exist. Mid-weekend that is the normal state, and an absent one is Saturday rather than an error.
ESPN, shl.se and hockeyallsvenskan.se no key held 15 minutes (30 for HockeyAllsvenskan), saved to the database so a restart starts warm
Fixtures, results and league tables for the Sports pages: Allsvenskan from ESPN, SHL from shl.se, HockeyAllsvenskan from its own site. Each league from the one source that publishes it whole, for free.
What is called
site.api.espn.com …/soccer/swe.1/standings
and the scoreboard a month per call: last, this and next
shl.se/api/statistics-v2/league-standings
and the season's schedule; the season's ids are read from the site's own filter
hockeyallsvenskan.se/pages/matcher and /pages/stats
asked for the page data payload (RSC), where the games and the table are plain JSON
How it is mapped
Worth knowing
Scores move during a game and tables after it, so a quarter of an hour. HockeyAllsvenskan comes as two pages of about 1.6 MB, so it is asked half as often. A timer refreshes one source every 20 seconds and pages only ever read the cache.
- All three are unofficial: none is a published API, and any of them can change without notice. Each league is read on its own, so one source changing costs that league and nothing else, and the page says which league is missing.
- A team is a key, not a source's id: football:sirius, hockey:frolunda. Ids belong to one source; a name survives a season and a source swap, and it is what a headline mentions. Favourites are stored as these keys.
- Some HockeyAllsvenskan names arrive UTF-8 encoded twice (Södertälje) and its table uses three team codes its games do not. Both are repaired in code and covered by a test.
Expressen and Aftonbladet no key held 30 minutes
Football and hockey headlines for the Sports pages, filtered to Allsvenskan, SHL and HockeyAllsvenskan.
What is called
feeds.expressen.se/fotboll/ and /hockey/
RSS; the league is in each link's path
rss.aftonbladet.se/…/sportbladet/fotboll/ and /hockey/
RSS; only the sport is in the link
How it is mapped
Worth knowing
Headlines are the slowest-moving thing on the page that is still news.
- Title, link, date and paper only: no description is copied, and a link that is not https is dropped. Reading one opens the paper's own page.
- Aftonbladet's hockey feed is mostly NHL and national-team news, so an item is kept only when its title names the league or a team in its table, as a whole word and in the same sport. Damallsvenskan is not Allsvenskan, and AIK in a football story is never filed under hockey.
GitHub Actions key held 2 minutes
Recent workflow runs, for the deploy history on the Health page.
What is called
/repos/{owner}/{repo}/actions/runs
the last 30, then the newest few per workflow
/repos/{owner}/{repo}/actions/runs/{id}/jobs
only for failures, and only the newest three
How it is mapped
Worth knowing
Nobody watches a deploy list continuously, and the token has a rate limit worth respecting.
- This is the only upstream that knows about a deploy that FAILED. Anything read from the running service — a restart time, a build stamp — can only describe deploys that worked, which is the half nobody needs telling about.
- Errors from it carry the status code and never the body. A GitHub error body can echo the request back, and the request carries the token.
OpenFreeMap no key held by your browser, never by the API
The map under the circuit on /f1/ and under the stops on /transit/: vector tiles cut to the OpenMapTiles schema, from OpenStreetMap's data, in a light and a dark palette. /f1/ can also swap it for satellite imagery, which comes from a second host - see the notes.
What is called
/styles/{positron|dark}
the style document, chosen from the theme
/planet/{version}/{z}/{x}/{y}.pbf
one vector tile per square, as you pan
/fonts/{fontstack}/{range}.pbf
glyph ranges, only for the labels actually drawn
/sprites/…
one sheet of icons for the whole style
How it is mapped
Nothing is mapped, because nothing arrives to be mapped. A tile is geometry to be drawn, not a document with fields, so there is no field of theirs that became a field of ours - the only decision here is which of the two styles to ask for, and that is read off the theme. The coordinates these maps are pointed AT come from the Trafiklab and Jolpica entries above, never from here.
Worth knowing
The one upstream that is not proxied: a browser talks to it itself. The others are proxied precisely so that it never has to; this one is the exception because tiles are images, fetched one per tile as you pan and zoom, and standing in front of a few hundred images a session would not be forwarding a call, it would be running a tile server. What that trade costs belongs here rather than in a footnote: the request carries the reader's IP address and, in the tile numbers themselves, roughly which part of the world they are looking at and how closely, to a service neither they nor this site has any relationship with. It is also the only entry on this list this code never sees happen, because nothing of it passes through the API, which is why it is written down here, on the privacy notice and on the cookies page rather than left to a network tab.
- Satellite is a SECOND tile host, offered behind a toggle on /f1/ only - EOX's Sentinel-2 cloudless, at tiles.maps.eox.at. Everything this entry says about the trade applies to it identically: the browser asks, the request carries an IP address and the tile numbers say which part of the world is being looked at. Two differences worth naming. Their licence requires a credit naming the year of the Copernicus data, which the map renders in its own attribution control and which the code builds from the same constant as the tile URL, so the imagery and the crediting of it cannot come apart. And it is free for non-commercial use only - a condition this site was read against rather than assumed past.
- It is on /f1/ and deliberately not on /transit/. A photograph of Spa is a pale ribbon through a forest and a photograph of Monza is an unmistakable oval; a photograph of a bus stop is a roof. Satellite hides the street name, the stop label and which way the road runs, which is the whole of what a departure board's map is for.
- Only the picture is theirs. MapLibre itself is served from /vendor, so no third party is in script-src on any page — which is the distinction that gets lost the moment somebody says “a third-party map”, and it is the half that would actually matter.
- Needing no key is the only reason a browser can be the one asking. The two keyless upstreams above are still proxied, because one call there serves every reader; a tile fetched by one browser serves that browser and nobody else, so proxying would buy nothing and cost a tile server.
- The provider was chosen by rendering one, which is the only way this particular thing can be checked. CARTO answers without a key and then prints “API KEY REQUIRED” across every tile - a 200 to curl and a ruined map to a person. OpenStreetMap’s own tiles are clean and keyless, but their usage policy says plainly they are not for an app’s basemap, and testing against them was throttled within minutes, which is that policy working rather than failing. This one asks for no key and sets no limit, and says so as its purpose.
- Both maps are an addition to a page that already works without one. Blocked, missing or failing, the library takes nothing down with it: the departures and the results still draw, and a place whose coordinate did not resolve says it has none rather than drawing an empty ocean at 0,0.
- This is written down twice more on purpose — the privacy notice and the cookies page both name the host. A request this code cannot see is one a reader can only learn about by being told, so being told is the whole control they have.
The two credentials are read from the environment on the server and stay out of every response, error message and log line. Upstream errors are reported by status code only, because an error body can echo the request back, and the request carries the key.
Rules the code follows
Each of these is here because breaking it caused a real bug in this codebase.
-
Check the type before coercing a number.
Number(null)is0,Number([])is0, and a query string can produce an array without being asked. Every parser guards ontypeoffirst. - An unknown role is the lowest role. Role names are an explicit list, not case-folding. Anything unrecognised resolves to the most restrictive answer.
- A comment may not claim a control the code does not have. If the code cannot be made true, the comment changes in the same commit.
- A signal that was never about the thing being checked proves nothing about it. A green test suite that never loads a module says nothing about that module.
- Never invent data on a dashboard. A panel with no source behind it says so.