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.

In your hands Served as files · no secret is in them The only thing that decides What it asks, on your behalf Your browser tzortzoglou.eu the portfolio · one call: the contact form app.tzortzoglou.eu signed in · pages only api.tzortzoglou.eu checks the token · re-reads your role · holds every key nothing below this line is reachable without it Firebase who you are Postgres messages · caches Ten upstreams three need a key Open-Meteo · Trafiklab ResRobot · Jolpica · GitHub Actions ESPN · shl.se · hockeyallsvenskan.se · Expressen · Aftonbladet none of these ten is ever called from your browserthe app’s map tiles are the one exception, fetched by your browser itself

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 tzortzoglou.eu — the public portfolio. Eleventy, output to _site/, served by GitHub Pages. Loads no Firebase at all. static
App app.tzortzoglou.eu — the signed-in app. A second Eleventy build of the same repository, output to _app/. The pages are static; what they show comes from the API, which is what checks who you are. static
API api.tzortzoglou.eu — Express, in its own private repository. Holds every key and is the only thing that talks to a database. node

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

current.temperature_2m current.temperature
current.apparent_temperature current.feelsLike
current.relative_humidity_2m current.humidity
current.wind_speed_10m current.windSpeed
current.is_day (0 or 1) current.isDay (boolean)
daily.temperature_2m_max[] daily[].max
current.european_aqi air.aqi

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

ProductAtStop.line line
ProductAtStop.catOutS category
ProductAtStop.operator operator
direction, or origin on an arrival board towards
time scheduledAt
rtTime, falling back to time expectedAt
expected minus scheduled delayMinutes derived
expected minus now inMinutes derived

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

vehicle.vehicle.id id
vehicle.position.latitude, longitude lat, lon
vehicle.position.bearing bearing
vehicle.timestamp (seconds) at
trip.tripId, through trips.txt to routes.txt route_short_name line derived
trips.txt trip_headsign, else the name of the trip's last stop (stop_times.txt to stops.txt) headsign derived

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

FirstPractice, SecondPractice, … sessions[].label
Circuit.Location.locality locality
Results[].positionText positionText
Results[].status status, passed through unedited
grid minus position gained derived
presence of a Sprint session sprint (boolean) derived

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

team name, any source team.id: sport:normalised-name derived
ESPN stats[] by name played, won, drawn, lost, gf, ga, gd, points
SHL GP, W, OTW, OTL, L played, won, otw, otl, lost
HockeyAllsvenskan teamCode the team name, via the games derived
state pre / in / post (each source's own words) state derived
the table and the results stories: leader, winning runs, biggest win derived

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

item title, link, pubDate title, link, at
which feed it came from source, sport
/allsvenskan/, /shl/, /hockeyallsvenskan/ in an Expressen link league derived
the league or one of its teams named in an Aftonbladet title league derived

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

run_started_at startedAt
updated_at minus run_started_at durationMs derived
head_sha, first seven sha
head_commit.message, first line message
the first failing step of the first failing job failedStep derived

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) is 0, Number([]) is 0, and a query string can produce an array without being asked. Every parser guards on typeof first.
  • 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.