How this is built
Three deployables, four data stores and five upstreams. 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: two of the five 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. It is the only upstream your browser contacts itself, so the diagram names it as an exception: its arrow 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. 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
Four tables: contact submissions, a row per process start, which migrations have run, and one row per page view: path, which of the two properties, the referring host and the time, and deliberately nothing else. No address, no cookie, no identifier of any kind, and not the full referring URL, only its host. 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. 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 a known account holder, and counting loads of your own service for the running of that service is operational telemetry, not audience measurement. 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 there is nothing in it to reach. A view row names nobody: there is no account on it, no address, and nothing to join it to a person with, which is deliberate.
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 five upstreams, in detail
Four of them are proxied. Two of those need a credential, which has to stay on the server; the other two so that one call serves every reader. The fifth is the basemap, and it is not proxied at all: your browser fetches its tiles itself, which makes it the only one of the five your browser has ever heard of. 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.
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.
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.
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, and so the only one of the five a browser talks to itself. The other four 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.
- 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.