Building KnowMore - Architecture of a Privacy-First Sports Calendar

Published on Nov 18, 2025

Building KnowMore - Architecture of a Privacy-First Sports Calendar

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.

Note

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

LayerTechnologyWhy
FrontendSvelteKit 2 + Svelte 5Fast, small bundles, runes make state management clean
UIshadcn-svelte (bits-ui v2) + TailwindCSS 4Headless primitives + token-based styling, Flowbite-API-compatible shims at $lib/components/ui/*.svelte
BackendPocketBase 0.36 (Go)Auth, database, real-time, file storage β€” all in one binary
ExtensionsGo (webauthn, notifications, federation, immich, hooks)Type-safe server-side logic β€” single-language Go backend
FederationFastAPI + PostgreSQLScraper service for Swiss football federation data
i18nParaglide JSType-safe translations, German and English
DeploymentDocker (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-full inside gridstack cells instead of CSS grid spans
  • Reactive visibility β€” a $effect tracks tile conditions (e.g., standings hidden for youth teams, player card hidden when no player) and syncs with gridstack via makeWidget()/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 subscription

After initialize():

  • watcher.data is a reactive $state array of records
  • Real-time events (create/update/delete) automatically merge into the array
  • watcher.loadMore() fetches the next page (supports infinite scroll via useIntersectionObserver)
  • 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:

PatternCollectionsPurpose
Coreusers, players, teams, clubs, events, responsesPrimary entities. Users have dashboard_layout (JSON) and followed_teams (relation). Events have immich_tag for photo linking.
Public viewsusers_public, players_public, teams_public, clubs_publicRead-only projections with restricted fields
Stats viewsuser_stats, player_stats, team_stats, club_stats, app_statsAggregated statistics via SQL views
Chatchat_messages, chat_reactions, chat_read_statusTeam communication with read receipts
Authpasskeys, roles, club_tokens, teams_tokensAuthentication and authorization
Notificationsnotifications, notifications_recipients, notifications_push_tokensPush notification system
Integrationusers_immich_keys, immich_config, federations, app_info, app_info_publicExternal 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:

  1. Layout wrappers β€” (app) group wraps with <RequireAuth>, (admin) group wraps with <RequireRole role="admin">
  2. Page-level guards β€” individual pages add <RequireRole> or <RequirePermission> for finer control
  3. 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:

RouteStrategyCacheTTLMax
Immich thumbnailsCacheFirstimmich-thumbnails1 year500
Immich previewsCacheFirstimmich-previews1 year100
People thumbnailsCacheFirstimmich-people-thumbs24h100
Video streamingNetworkOnlyβ€”β€”β€”
Events/teams/players/responsesNetworkFirst (3s timeout)pb-api24h100
Users/clubs/app_infoStaleWhileRevalidatepb-static7 days50

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:

  1. Node stage (node:22-alpine) β€” builds the SvelteKit frontend with pnpm build, setting VITE_POCKETBASE_URL="" so API calls go to the same origin. __APP_VERSION__ and __BUILD_TIME__ are injected via Vite’s define block for display in settings
  2. Go stage (golang:1.25-alpine) β€” compiles the PocketBase binary with all Go plugins (hooks compile into the binary β€” no separate pb_hooks/ directory needed)
  3. 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:

GroupAuthPagesPurpose
(main)/(app)<RequireAuth>29Main app: dashboard, teams, calendar, chat, gallery, profile, club, federation (standings, schedule, match detail)
(main)/(admin)<RequireRole role="admin">10Club administration: users, teams, theme, integrations
(main)/(auth)None / partial8Login, 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:

  1. Cron job (federation_sync) β€” runs daily at 6 AM. Fetches all team schedules from the scraper, upserts into PocketBase events by match_id, and creates player responses for new events.
  2. Webhook receiver (POST /api/federation/webhook) β€” receives real-time pushes when the scraper detects match changes via Postgres NOTIFY/LISTEN. Authenticated via SCRAPER_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:

TypeIconSource
leagueShieldFederation league matches
cupAwardCup/knockout matches
friendlyHeartFriendly matches
tournamentUsers groupTournament 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.