Building KnowMore - Architecture of a Privacy-First Sports Calendar
Published on Nov 18, 2025
KnowMore is a privacy-focused calendar and team management app for sports clubs. It runs entirely self-hosted β one Docker container per club, no external services, no tracking.
This post covers the architecture: why we chose this stack, how the pieces fit together, and what we learned building it.
Updated May 2026 to reflect the current architecture (v0.7.3+), the migration from Flowbite Svelte to shadcn-svelte at the end of April 2026, and the route-group / store-decoupling refactor on refactor/cleanup (May 2026).
Table of Contents
- The Stack
- System Architecture
- Why SPA, Not SSR
- The Three-Layer Store Architecture
- The watch() Utility
- Go Backend
- Data Model
- Auth and Security
- PWA
- Deployment
- Internationalization
- Route Structure
- Federation Integration
The Stack
| Layer | Technology | Why |
|---|---|---|
| Frontend | SvelteKit 2 + Svelte 5 | Fast, small bundles, runes make state management clean |
| UI | shadcn-svelte (bits-ui v2) + TailwindCSS 4 | Headless primitives + token-based styling, Flowbite-API-compatible shims at $lib/components/ui/*.svelte |
| Backend | PocketBase 0.36 (Go) | Auth, database, real-time, file storage β all in one binary |
| Extensions | Go (webauthn, notifications, federation, immich, hooks) | Type-safe server-side logic β single-language Go backend |
| Federation | FastAPI + PostgreSQL | Scraper service for Swiss football federation data |
| i18n | Paraglide JS | Type-safe translations, German and English |
| Deployment | Docker (single container) | One command to deploy, one file to back up |
The guiding principle: fewer moving parts. The core app runs inside one process on one machine. The federation scraper is the one optional external service β a separate stack that feeds match data into PocketBase via webhook and cron sync.
System Architecture
PocketBase serves both the API and the SvelteKit static files. When a browser hits the server, it gets the SPA from pb_public/. The SPA then talks to PocketBaseβs REST API and subscribes to real-time events β all on the same origin, no CORS issues.
Why SPA, Not SSR
KnowMore is a client-side SPA using adapter-static. There are no +page.server.ts files. All data loading happens client-side via PocketBaseβs JavaScript SDK.
This was a deliberate choice:
- Single container deployment β PocketBase serves the static files directly, no Node.js server needed
- Offline capability β the service worker caches the entire app shell
- Simpler hosting β any cheap VPS or NAS (like Unraid) that runs Docker works
- Real-time first β all data flows through reactive stores with live subscriptions, not server-rendered pages
The tradeoff is no server-side rendering for SEO, but that doesnβt matter here β this is an authenticated app, not a content site.
The Three-Layer Store Architecture
State management is organized into 21 store files across three subfolders with barrel exports, all initialized in the (main) group layout (routes/(main)/+layout.svelte β the composition root for regular-user flows; the actual root routes/+layout.svelte is a minimal ~25 LOC shell that handles only global CSS, fonts, and setAuthContext()):
stores/
βββ core/ β userData, userPermissions, team, teamStoreManager, users, statistics, importedEvents, pwa
βββ features/ β chat, chatStoreManager, chatUnread, notifications, pushNotifications, immich, federation
βββ ui/ β settings, theme, tour, network, layoutBackground, toast
βββ index.ts β Root barrel re-exporting all 3
21 store contexts are initialized at the layout level using Svelte 5βs createContext pattern. Each subfolder has a barrel export, and the root index.ts re-exports everything. Import from the relevant barrel:
import { getUserDataContext } from '$lib/stores/core';
import { getChatContext } from '$lib/stores/features';
import { getSettingsContext } from '$lib/stores/ui';This means every page gets access to reactive data without any per-page boilerplate β just call getUserDataContext() and you have the current user, their teams, their players, everything.
UserData uses a Runed FiniteStateMachine to manage loading states (uninitialized β loading_core β loading_dependent β ready | error), with parallel data fetching at each stage for faster load times. UserPermissions has been extracted to its own class to keep permission logic separate from data loading. The FSM was previously 4-state with a loading_extras phase for Immich boot and dashboard photo preload; in the May 2026 cleanup those were moved out of UserData into parallel composition-root tasks and the FSM collapsed to 3 working states.
The stores use $state and $derived runes throughout. There are zero $effect calls in any store file. All reactivity comes from state, derived values, and explicit method calls. Effects are reserved for components that need DOM interaction.
TeamStoreManager
The most interesting pattern is the TeamStoreManager. Instead of one big store holding all team data, each team gets its own TeamStore instance:
- Lazy β stores created only when a team is accessed
- Persistent β navigate away and back, the store and its data are still there
- Independent β each team has its own
watch()subscription and pagination - Aggregatable β the dashboard collects events across all team stores
This pattern emerged from real usage. A parent with kids on three teams shouldnβt wait for all three teams to load before seeing the first event. Each team loads independently, and the UI fills in as data arrives.
Customizable Dashboard
The dashboard uses gridstack.js v12 for a fully customizable tile layout. Users can drag, resize, and show/hide tiles β their arrangement is persisted as JSON in the users.dashboard_layout field.
Key implementation details:
- BentoTile auto-detects grid mode via Svelte context β uses
w-full h-fullinside gridstack cells instead of CSS grid spans - Reactive visibility β a
$effecttracks tile conditions (e.g., standings hidden for youth teams, player card hidden when no player) and syncs with gridstack viamakeWidget()/removeWidget(). Data-driven conditions always take precedence over user overrides. - Edit mode β a drag handle (grip icon) on each tile initiates drag; touching elsewhere scrolls normally on mobile
- Tile types include: NowNext, NextTraining, StandingsPreview, TeamSpotlight, FollowedTeam, CoachActions, ClubActions, PendingResponses, PlayerCard, ChatPreview, Gallery, PlayersStrip
Users can also follow teams within their club (users.followed_teams relation) to get a FollowedTeamTile on their dashboard showing rank, last result, and next match.
Centralized User Cache
PocketBase API rules block direct access to user records for privacy. The users.svelte.ts store works around this by loading user data indirectly β through player guardian relationships and team coach expansions β and caching complete user objects in a SvelteMap. This reduced API calls from 15-20 per session to 3-4 during initialization.
The watch() Utility
The watch() function at $lib/utils/watch.svelte.ts is the core data primitive. It combines PocketBaseβs getList() pagination with real-time subscribe('*') into a single reactive interface:
const watcher = watch<EventsResponse>(pb, 'events', {
filter: pb.filter('teams ~ {:teamId} && startTime >= @yesterday', { teamId }),
sort: 'startTime',
pageSize: 20,
expand: 'teams'
});
await watcher.initialize(); // fetches first page + starts subscriptionAfter initialize():
watcher.datais a reactive$statearray of records- Real-time events (create/update/delete) automatically merge into the array
watcher.loadMore()fetches the next page (supports infinite scroll viauseIntersectionObserver)watcher.reconfigure()changes filters at runtime (e.g., switching between coach and parent views)watcher.refetch()re-fetches everything (used after reconnection)
The two-step pattern (create, then initialize()) is intentional β it lets the store set up derived values before data starts flowing.
Go Backend
PocketBase handles most backend concerns out of the box: auth, CRUD, file storage, real-time subscriptions. But some features need server-side logic beyond what PocketBase provides.
KnowMore uses a single-language Go backend β no JavaScript hooks. All server-side logic is compiled into the PocketBase binary as Go packages. main.go registers five plugin packages plus migrations and static file serving:
webauthn.Register(app) // FIDO2/passkey authentication
notifications.Register(app) // Push notifications, chat hooks, cron cleanup
federation.Register(app) // Federation sync via cron + webhook
immich.Register(app) // Photo proxy, albums, tags, admin setup
hooks.Register(app) // Core lifecycle hooks (teams, users, responses, tokens)
Each package follows the same Register(app) convention β binding its own routes, hooks, and cron jobs to the PocketBase instance. A shared auth package provides role-based access control used across all packages:
pb/
βββ auth/ # Shared role checks (RequireAdmin, RequireAdminAccess, IsAdmin)
βββ hooks/ # Core PocketBase hooks (6 files: teams, users, responses, tokens, routes)
βββ immich/ # Immich photo proxy (11 files: proxy, albums, tags, people, admin)
βββ federation/ # Federation scraper sync (cron + webhook)
βββ notifications/ # Push notifications, VAPID, chat hooks
βββ webauthn/ # Passkey/WebAuthn authentication
βββ club_data/ # Static club data assets
βββ pb_migrations/ # Schema migrations
βββ main.go
The immich package is the largest (11 files) β it provides a full server-side proxy to an Immich photo server, keeping API keys off the client. Media proxy endpoints stream binary data (thumbnails, previews, video) without requiring PocketBase auth since asset UUIDs are not guessable. The search/browse endpoints that return those UUIDs still require authentication.
Data Model
34 PocketBase collections (excluding system tables), organized into patterns:
| Pattern | Collections | Purpose |
|---|---|---|
| Core | users, players, teams, clubs, events, responses | Primary entities. Users have dashboard_layout (JSON) and followed_teams (relation). Events have immich_tag for photo linking. |
| Public views | users_public, players_public, teams_public, clubs_public | Read-only projections with restricted fields |
| Stats views | user_stats, player_stats, team_stats, club_stats, app_stats | Aggregated statistics via SQL views |
| Chat | chat_messages, chat_reactions, chat_read_status | Team communication with read receipts |
| Auth | passkeys, roles, club_tokens, teams_tokens | Authentication and authorization |
| Notifications | notifications, notifications_recipients, notifications_push_tokens | Push notification system |
| Integration | users_immich_keys, immich_config, federations, app_info, app_info_public | External services config. immich_config stores server-side Immich connection details. app_info.cache_version enables remote cache invalidation. |
The *_public view collections are a privacy pattern β they expose only the fields needed for UI display (name, avatar) without leaking emails, guardian relationships, or other sensitive data. PocketBase API rules enforce this at the database level.
Auth and Security
Three Layers of Auth Guards
Every page is protected at multiple levels:
- Layout wrappers β
(app)group wraps with<RequireAuth>,(admin)group wraps with<RequireRole role="admin"> - Page-level guards β individual pages add
<RequireRole>or<RequirePermission>for finer control - API rules β PocketBase collection rules enforce authorization server-side, independent of the frontend
<!-- (admin)/+layout.svelte -->
<RequireRole role="admin">
{@render children()}
</RequireRole>
<!-- Coach-only team management page -->
<RequireRole roles={['coach', 'admin']} teamId={teamId}>
<TeamManagement />
</RequireRole>Passkeys
KnowMore supports passwordless login via WebAuthn/FIDO2. The Go backend handles credential registration and authentication ceremonies, while the frontend uses @simplewebauthn/browser. Users can log in with fingerprint, face recognition, or device PIN β no passwords to leak or forget.
Privacy by Architecture
- Single-club deployment β each club is a completely isolated instance
- No analytics β zero tracking scripts, no cookies, no third-party requests
- Self-hosted β data never leaves the clubβs server
- Public views β sensitive fields are never exposed through the API
- GDPR by design β thereβs no central database to breach
PWA
KnowMore runs as a Progressive Web App via @vite-pwa/sveltekit:
- Installable β add to home screen, launches like a native app
- Offline shell β service worker caches the app shell and API responses
- Push notifications β web push via the notifications Go plugin
- Silent updates β new versions are fetched in the background and applied on next navigation
- Club branding β the PWA manifest uses club-specific name, colors, and logo
PWA logic is encapsulated in PwaStore (stores/core/pwa.svelte.ts) β a class-based store handling service worker registration, install prompt management, and silent updates. The root layout initializes it via pwa.initialize() in onMount. InstallPrompt.svelte reads from the store via getPwaContext() for iOS Safari detection and prompt dismissal (persisted to sessionStorage).
Service Worker Caching
The service worker uses Workbox with route-specific caching strategies:
| Route | Strategy | Cache | TTL | Max |
|---|---|---|---|---|
| Immich thumbnails | CacheFirst | immich-thumbnails | 1 year | 500 |
| Immich previews | CacheFirst | immich-previews | 1 year | 100 |
| People thumbnails | CacheFirst | immich-people-thumbs | 24h | 100 |
| Video streaming | NetworkOnly | β | β | β |
| Events/teams/players/responses | NetworkFirst (3s timeout) | pb-api | 24h | 100 |
| Users/clubs/app_info | StaleWhileRevalidate | pb-static | 7 days | 50 |
Immich images use CacheFirst because thumbnails and previews are immutable β same UUID, same pixels. PocketBase data collections use NetworkFirst with a 3-second timeout (falls back to cache offline). Slowly-changing reference data (users, clubs, app settings) uses StaleWhileRevalidate for instant display while revalidating in the background.
Cache invalidation uses three levels: automatic TTL expiration, admin remote clear (bump app_info.cache_version in PocketBase), and deploy-time clear (bump CACHE_VERSION constant in the service worker).
Deployment
Bundled Single-Container
The production Dockerfile uses a three-stage multi-stage build:
- Node stage (
node:22-alpine) β builds the SvelteKit frontend withpnpm build, settingVITE_POCKETBASE_URL=""so API calls go to the same origin.__APP_VERSION__and__BUILD_TIME__are injected via Viteβsdefineblock for display in settings - Go stage (
golang:1.25-alpine) β compiles the PocketBase binary with all Go plugins (hooks compile into the binary β no separatepb_hooks/directory needed) - Runtime stage (
alpine:latest) β copies the binary, static files, and migrations into a minimal image
The SvelteKit output goes into pb_public/, which PocketBase serves alongside the API. One port (8090), one process, one container.
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090", "--origins=${APP_DOMAIN}"]
Backups are simple: copy the SQLite file. The entire database, including uploaded files, lives on a single volume mount.
Requirements
A small club (under 10 teams, under 200 users) runs comfortably on a 5 EUR/month VPS with 1GB RAM. The SQLite database stays small β a full season of events, responses, and chat messages for a mid-sized club fits in under 50MB.
Internationalization
Paraglide JS provides type-safe i18n with compile-time checks. Every translation key is a typed function:
import * as m from '$lib/paraglide/messages';
m.event_training(); // "Training" / "Training"
m.squad_selection(); // "Squad Selection" / "Aufgebot"
m.response_deadline({ date }) // "Respond by {date}" / "Antworten bis {date}"Missing keys are caught at build time, not at runtime in production. The app supports German and English, with Swiss German football terminology (Aufgebot, Junioren, etc.) where it matters.
Route Structure
A minimal root layout (routes/+layout.svelte, ~25 LOC β global CSS, fonts, favicon, setAuthContext()) hosts two top-level branches: the (main) group for all regular-user flows, and a literal super/ directory for superuser bootstrap. The (main) layout (routes/(main)/+layout.svelte, ~412 LOC) is the demoted composition root that owns store initialization, real-time reconnection, and chrome.
Inside (main), three layout groups organize the regular-user pages:
| Group | Auth | Pages | Purpose |
|---|---|---|---|
(main)/(app) | <RequireAuth> | 29 | Main app: dashboard, teams, calendar, chat, gallery, profile, club, federation (standings, schedule, match detail) |
(main)/(admin) | <RequireRole role="admin"> | 10 | Club administration: users, teams, theme, integrations |
(main)/(auth) | None / partial | 8 | Login, register, join links, email verification |
Superuser flows live outside (main) under super/ (a literal directory, not a group, since (super) would collide with (main)/+page.svelte at /): /super (landing), /super/login, /super/init. The old /admin-login and /admin-init URLs no longer exist.
Team pages use a nested layout at (main)/(app)/teams/[id]/+layout.svelte that initializes the teamβs store via teamStoreManager.getOrCreateStore(teamId) and provides it via context. All child pages (calendar, players, events, chat, standings, schedule) get the team data without any additional setup.
The admin chrome (sidebar + lg:ml-64 spacing + slim top bar with mobile hamburger, page title, back-to-dashboard, logout) lives in (main)/(admin)/+layout.svelte β when on /admin/* the regular UniversalAppHeader is suppressed so admins get a focused workspace. Page-type checks (isChatPage, isAdminArea, isAuthArea, isDashboardPage, isInActiveChat) are pure functions in $lib/utils/routes.ts, called at the use-site via $derived(isChatPage(page.url)) β no store observes the URL.
Federation Integration
KnowMore integrates with the Swiss Football Federation (football.ch) to pull in match schedules, standings, and match details automatically. This is the one area where the architecture steps outside the single-container model.
The Scraper Service
The federation data lives on football.ch, which has no public API. A separate scraper service runs alongside KnowMore β a FastAPI application backed by PostgreSQL, using Playwright Firefox for browser-based scraping.
The scraper exposes a REST API at localhost:8095 with endpoints for standings, schedules, match details, and team summaries. The SvelteKit frontend reaches it via a Vite dev proxy (/scraper β localhost:8095), so the browser never talks to the scraper directly in production.
Dual Sync: Cron + Webhook
Match data flows into PocketBaseβs events collection through two mechanisms:
- Cron job (
federation_sync) β runs daily at 6 AM. Fetches all team schedules from the scraper, upserts into PocketBaseeventsbymatch_id, and creates player responses for new events. - Webhook receiver (
POST /api/federation/webhook) β receives real-time pushes when the scraper detects match changes via Postgres NOTIFY/LISTEN. Authenticated viaSCRAPER_API_KEY. Handles both match and tournament events, with derby support (multiple subscribed teams per match).
Event Type System
The generic match event type was replaced with specific types that map to federation data:
| Type | Icon | Source |
|---|---|---|
league | Shield | Federation league matches |
cup | Award | Cup/knockout matches |
friendly | Heart | Friendly matches |
tournament | Users group | Tournament events with participants |
training | β | Manual (not from federation) |
Frontend Data Loading
Federation pages (standings, schedule, match detail) use a different data loading pattern than the rest of the app. Instead of the watch() utility (which is designed for PocketBase collections with real-time subscriptions), they use direct {#await} blocks on fetch calls to the scraper API:
{#key forceRefresh}
{#await federation.getStandings(clubId, teamId, { season: CURRENT_SEASON })}
{@render skeletonTable(true)}
{:then standings}
<!-- render standings table -->
{:catch error}
<Alert color="red">
<span class="font-medium">{error?.message ?? 'Failed to load standings'}</span>
<Button size="xs" color="red" onclick={handleRefresh}>{m.try_again()}</Button>
</Alert>
{/await}
{/key}The FederationStore caches responses in SvelteMap instances keyed by clubId:teamId:season, with a pending Set for in-flight request deduplication. The {#key forceRefresh} wrapper enables retry by incrementing a counter, which destroys and recreates the {#await} block.
This two-pattern approach (watch for PocketBase, await for external APIs) keeps each data source using the pattern that fits it best.
This is the first post in the KnowMore series. For a non-technical overview of what KnowMore does, see KnowMore - A Sports Calendar That Actually Works. For a deep dive into the real-time data system, see KnowMore Real-Time Data Loading.