FindMore - A Public Football PWA on a Shared Cache

Published on Oct 26, 2026

FindMore - A Public Football PWA on a Shared Cache

FindMore answers one question for parents, coaches and curious neighbours: how is my team doing, and when do they play next? Star a club or a team, and the app shows standings, this week’s fixtures, results, squads and, since this autumn, a player’s season across age groups. It is public, needs no login, installs as a PWA and costs nothing to run, because it has no backend of its own.

This post is about the architecture, and mostly about one decision made in October: pages no longer own their data. One cache does, and every page is a pure projection of it.

Table of Contents

The Stack

LayerTechnologyWhy
FrontendSvelteKit 3 + Svelte 5, adapter-static, ssr = falsePure SPA; no server to run, deep links served by nginx
DataThe public federation-scraper APIStandings, schedules, results, squads, player stats for FVRZ
Shared code@simpelekees/knowmore-federation (npm)Typed client, shaping helpers and table components, shared with KnowMore
StateSvelteMap cache + runed resourceOne store, pages read only; route params are the one reaction
PersistencelocalStorageFavourites and a 24-hour cache snapshot; no accounts
PWAOwn service worker on workboxShell precached, scraper network-first, SSE never cached
i18nParaglide JS 2German default, English
StatsPlausible, same-origin proxyAnonymous, cookieless

FindMore is the third sibling of KnowMore and PlayMore. It deliberately leaves out what those two have, accounts, chat, photos, a database, and keeps the parts that make football data readable on a phone.

System Architecture

There are three pieces and none of them is a server I wrote for FindMore: the static build on SWAG, the federation scraper that KnowMore already needed, and the browser’s own storage. The scraper is public and sends CORS headers, so the app calls it directly; in development a Vite proxy under /scraper points at the same instance.

One Cache, Three Ways In, One Way Out

Until October every page kept its own $state, loaded it in onMount and threw it away on leave. Switching between Start, Woche and Dashboard meant a skeleton each time and the same endpoints fetched three times. The team page fired six requests on open and six more when you came back.

The replacement is a single cache in src/lib/stores/cache.svelte.ts: a SvelteMap from a key such as standings:1554:38686 to an entry that is created once and mutated in place.

export class Eintrag<T> {
	/** Last good payload. Raw (no deep proxy) — replaced wholesale on refresh. */
	data = $state.raw<T | null>(null);
	geholt = $state(0);   // last successful fetch
	versucht = $state(0); // last attempt, success or error
	laedt = $state(false);
	fehler = $state<unknown>(null);
	laufend: Promise<void> | null = null; // in-flight, shared by concurrent callers

	/** True once a fetch finished at least once — the skeleton goes away. */
	get bereit() {
		return this.versucht > 0;
	}
}

Three details matter more than they look:

  • data is $state.raw. A 20 kB schedule gets no deep proxy; a refresh swaps the object.
  • bereit is the skeleton switch and it counts attempts, not successes. A dead scraper shows the empty state, never a skeleton forever, and an error keeps the last good data.
  • Concurrent requests for one key share one promise.

Svelte 5 forbids reactive writes inside $derived, so “ask the cache” is two calls. holen() is imperative: it creates the entry and fetches if it is missing or older than its maxAlter. lese() is the read, safe inside a derived, and undefined until something filled the key. quelle(name, maxAlter, fetcher) builds both for one endpoint (src/lib/daten.ts):

SourceKeyMax age
teamSummary, teamStandings, teamSchedule, teamResults, teamStats::2 min
clubWeekScheduleweek:2 min
clubDetail, teamGroupclub:, group:…1 h
playerStats, playerMatchesplayer:10 min

Who fills the cache is the architectural rule, and it is the one I had to be corrected on. My first cut triggered holen() from an $effect in each page. Kees’s verdict was “effects are for side effects, not loads”, which is also how KnowMore boots. So there are exactly three fillers, and no page is one of them:

vorladen() runs once in the root layout and is the equivalent of KnowMore’s AppBoot (src/lib/daten.ts:63):

export function vorladen(teams: readonly Team[], clubs: readonly Club[]) {
	const starred = new Set(clubs.map((c) => c.clubId));
	for (const t of teams) {
		clubDetail.holen(t.clubId);
		teamSummary.holen(t.clubId, t.teamExtId);
		teamStandings.holen(t.clubId, t.teamExtId);
		if (!starred.has(t.clubId)) {
			teamSchedule.holen(t.clubId, t.teamExtId);
			teamResults.holen(t.clubId, t.teamExtId);
		}
	}
	for (const c of clubs) {
		clubDetail.holen(c.clubId);
		clubWeekSchedule.holen(c.clubId);
	}
}

Teams of a starred club skip their own schedule, because the week view shows them through the club’s week. When a club is unstarred, the favourites store re-runs vorladen for that club’s teams, which just became “solo” and would otherwise never get a schedule.

Start, Woche and Dashboard now contain zero effects. The Dashboard is a derived over the favourites (src/routes/dashboard/+page.svelte:24):

const cards = $derived(
	favorites.teams.map((t) => {
		const s = teamSummary.lese(t.clubId, t.teamExtId);
		const st = teamStandings.lese(t.clubId, t.teamExtId);
		return feed.teamCardAus(t, s, st);
	})
);

The skeleton is per card, so starring a new team does not blank the page. And teamCardAus is pure: the old buildTeamCard mutated summary.rank.position, which would now be a shared cache object. It copies instead.

The one legitimate reaction

Reacting to a route parameter is a side effect, so the team page uses runed’s resource on page.params (src/routes/teams/[clubId]/[teamId]/+page.svelte:47). The resource never returns data. It only awaits holen() for the five sources and the view reads lese():

Opened from a Dashboard card that means two requests instead of six, the other four are already there. Team A to B to A costs nothing on the way back, and a late response for B lands in B’s entries, never in A’s view. The lesson behind the resource: SvelteKit reuses the component when only the params change, so copying page.params into a const and fetching in onMount left the old team on screen when you clicked a table link to another one.

Cold start

After each successful fetch the cache is written to localStorage under findmore_cache_v1, debounced, newest first, 24 hours, about a megabyte. On module load it is read back and restored entries count as fetched, so cards paint before a single request leaves the phone and only stale keys refresh. Every storage access sits in a try/catch; blocked storage costs the cold start, not the app. That last clause earned its own fix in October when Chrome’s “block all site data” made a theme library throw at import time and killed the whole bundle before any component ran.

Verification is not eyeballing. pnpm test:cache drives the installed Chromium over CDP and counts requests per navigation.

The Shared Federation Package

The scraper client, the schedule and standings helpers and three table components were byte-identical copies in FindMore and KnowMore. Since October they live in one npm package, @simpelekees/knowmore-federation, published from a version tag through a GitHub Action with Trusted Publishing. FindMore hands the package its Paraglide labels once at boot through setFederationLabels() (src/routes/+layout.svelte:16); the tables render themselves.

The package also owns the small rules that bit both apps. The latest, shipped this week as 0.2.1: a schedule row is a link to the match page only when the game has a score and no status. The federation lists a score for an abandoned match, but the scraper has no match report for it and answers 404. One line in hasMatchDetail() and both apps stopped linking into an error page.

The Service Worker, Written by Hand

FindMore used @vite-pwa/sveltekit until the move to SvelteKit 3, where that plugin produces no worker at all: it injects during the SSR build and Kit 3 builds the worker afterwards. Moving the worker into our own code was step A of the migration, and it surfaced a surprise. The generated worker had bound navigations to /, which was not in its precache, so offline start had never worked while the worker was happily registered.

The worker (src/service-worker.ts) is about eighty lines on workbox:

  • The precache comes from Kit 3’s $app/manifest: the hashed build files with no revision, the contents of static/ with the build version as revision, and the SPA shell index.html that adapter-static writes.
  • Navigations are network-first with the precached shell as the error fallback, so the app starts offline on any route, not only visited ones.
  • The SSE endpoint of the scraper must stay NetworkOnly: cloning an event stream into a cache hangs until the stream closes.
  • skipWaiting plus clientsClaim, and the layout reloads once on controllerchange, never on the first install.

Two checks run before every deploy, both over CDP against a plain static server, never vite preview, which runs Kit’s server runtime and has no index.html to precache: one proves the manifest and the precache count with the shell in it, the other replays a deploy as a device sees it, old build to new build, one registration, one reload, then stops the server and expects /week to render.

A messenger bot fetches a shared URL once, without JavaScript, and builds its card from og: tags. A static SPA only ever gives it the generic FindMore card. The fix needs neither SSR nor prerendering: SWAG maps the user agent to a bot flag, and for a match URL from a bot it proxies to the scraper’s /og/match/{id}, which returns a minimal HTML page with title, description and the published summary poster. Humans get index.html as before. An unknown match returns a generic card with status 200, because a 404 makes some messengers show no card at all.

The match page itself can play published match summaries from any KnowMore instance and has a share button that falls back to the clipboard where the share sheet does not exist.

Privacy and Counting

Favourites never leave the device. Visitor counts come from a self-hosted Plausible, loaded same-origin through an nginx proxy so the stats domain stays behind the identity provider, no cookies, no identifiers. Because Plausible hashes visitors with a daily-rotating salt, the same person on five days is five unique visitors, which makes the numbers for a weekly-use app meaningless. FindMore therefore sends two plain events: “new device” once per device and “returning” at most once a day, both keyed on a date flag in localStorage that is never transmitted (src/lib/analytics.ts:39). It honours Plausible’s own opt-out and skips silently when storage is blocked.

Deployment

pnpm deploy builds with the public scraper URL baked in and rsyncs the output to SWAG. Deploys build from the working tree and do not need GitHub. The vhost does four things: try_files … /index.html for deep links, no-store on the service worker and the manifest so Cloudflare never holds an old worker for four hours, the Plausible proxy include, and the bot map for link previews. The manifest is a frozen static file, byte-identical with what production served, because Android’s install identity hangs on it.

What FindMore Leaves Out

Compared with its siblings, and on purpose: no accounts, no backend, no push, no chat, no photos, no dark-mode library drama after October. The cache has no live channel either; freshness is age plus foregrounding, and SSE from the scraper would be the next step. The match and group pages still fetch on their own, each one quelle line away from the cache. And async $derived, once it ships without a flag, would turn the team page’s resource into a plain derivation, for all three siblings at once.

Summary

ConcernApproach
Data ownershipone SvelteMap cache; pages are $derived over lese(), zero effects
Fillersboot, favourites store, route resource; visibilitychange refreshes stale keys
Freshnessstale-while-revalidate, 2 min for live data, 1 h for club metadata
Cold start24 h localStorage snapshot restored as “fetched”
Shared code@simpelekees/knowmore-federation, tag-driven npm publish
Offlineown workbox worker: shell precached, scraper network-first, SSE never cached
PreviewsSWAG routes bots to the scraper’s OG endpoint
PrivacylocalStorage only; Plausible same-origin with a device count instead of visitors

The whole app is a projection of two inputs, the favourites and the scraper, and the architecture is what it took to make that true.


This is the first post in the FindMore series. The player pages and the group-label pages for teams the catalog does not know are the next candidates.