KnowMore v0.4.1 - Watch Consolidation, Team Ownership, and Form Overhaul
Published on Feb 14, 2026
KnowMore v0.4.1 landed across four branches of work: consolidating real-time subscriptions into the watch() utility, flipping the team-player relationship, overhauling the form system, and cleaning up the data model. Here’s what changed and why.
Table of Contents
watch() Consolidation
KnowMore’s watch() utility combines PocketBase’s paginated getList() with real-time subscribe('*') into a single reactive interface (covered in detail in the real-time data loading post). Before v0.4.1, several stores still used manual subscriptions alongside watch() — a leftover from before the utility existed.
What Was Consolidated
userData responses had the most manual plumbing — loadUserResponses(), setupSubscriptions(), and handleResponseUpdate() were three separate functions managing the same lifecycle that watch() handles automatically. These were replaced by a single responsesWatcher:
Team players were similarly converted. The team store had manual subscription logic for tracking player changes — now it uses a playersWatcher with mutatePlayers() to handle filter boundary cases (when a player is removed from a team, PocketBase doesn’t send a real-time event because the record no longer matches the filter).
Re-Fetch Elimination
The watch() utility itself had a subtle inefficiency: on every real-time event, it called pb.collection().getOne() to re-fetch the record with expands. This was unnecessary — since PocketBase v0.21+, subscription payloads already include expanded relations when the subscribe() call passes the expand option.
Removing the re-fetch eliminated ~50+ redundant API calls per session. The real-time handler now uses e.record directly:
case 'update':
data = data.map(item =>
item.id === recordToUse.id ? recordToUse : item
);
break;Team-Owns-Players
The biggest architectural change is a relationship flip. Previously, players.teams was a multi-relation field listing which teams a player belonged to. This had a permissions problem — adding a player to a team required write access to the player record.
The refactor moves ownership to the team: teams.players is now the source of truth.
With teams owning the relationship, a coach can manage their roster by updating their own team record — no cross-collection permission escalation needed.
Frontend Impact
The userData store gained helper methods that derive team membership from the team side:
getTeamsForPlayer(playerId: string): string[] {
return this.allTeams
.filter(team => team.players?.includes(playerId))
.map(team => team.id);
}The $derived chains (userTeams, affiliatedTeams, coachedTeams) all recompute from teams.players now. Because they use $derived, the change is transparent — components that consume these values didn’t need updating. Over 20 components were touched, but most changes were mechanical: swapping player.teams?.includes(teamId) to team.players?.includes(playerId).
Schema Updates
PocketBase views (club_stats, team_stats, player_stats, user_stats) were rewritten to query from the team side. The JSVM hooks for join flow and token validation were simplified (these hooks were later migrated entirely to Go in v0.7.0 — see the v0.6-v0.7 release post). Deprecated fields (players.clubs, teams.club) were removed since KnowMore uses single-club deployment — every team belongs to the one club by definition.
A one-time sync endpoint migrated existing production data from players.teams to teams.players, then was removed.
Form Overhaul
The form system was modernized in six phases across one branch — the largest refactor by line count.
Zod v4 Migration
All 7 form pages used zodClient (Superforms v1 adapter). This was replaced with the zod4 adapter, eliminating 35 type errors that had accumulated from the Zod 3→4 upgrade. The migration also fixed validation on several forms that were silently broken.
Shared Components
Two components were extracted to eliminate duplication:
AvatarUploader — pulled from both PlayerForm and UserForm. Handles file upload, Immich library integration, preview, and crop. Net reduction: 630 lines.
FormField — a wrapper around Formsnap’s Field/Control/Label/FieldErrors that provides consistent styling and icon support. 18 fields across 6 forms were converted. Net reduction: 409 lines.
Error Handling
Three separate error handling functions were consolidated into one handlePocketBaseError() that maps PocketBase validation errors to Superforms field errors. It handles the common patterns: field-level validation (email taken, name too short), generic errors, and network failures.
Dirty Tracking and Unsaved Changes
Form dirty state was tracked via a $effect that compared current values against initial values — fragile and prone to false positives. This was replaced with Superforms’ built-in $tainted which tracks actual user interaction.
Four forms (player, user, event, team preferences) gained an UnsavedChangesModal using Superforms’ taintedMessage callback, which intercepts SvelteKit navigation when the form has unsaved changes.
Data Model Cleanup
Removed Fields
| Field | Collection | Why |
|---|---|---|
immich_api_key | users, _superusers | Moved to users_immich_keys — prevents key leaking via expands |
players.clubs | players | Single-club architecture — redundant |
teams.club | teams | Single-club architecture — redundant |
Added: timezone on Users
A new text field storing the IANA timezone string (e.g., Europe/Zurich). Auto-detected from the browser via Intl.DateTimeFormat and synced silently on each login. Used by the Go backend for timezone-aware push notifications.
User Edit Form
The user edit page was simplified: removed the emailVisibility toggle, back button, and cancel button. Added a read-only timezone display, wider desktop layout, and the unsaved changes modal from the form overhaul.
Bug Fixes
- Push notification localization:
createNotificationWithRecipientswas stripping event data before passing to the push system — all users got hardcoded German. Fixed by forwarding the full data map so per-user language and timezone are applied. - Duplicate response loading: Responses were double-loaded in team store init. Added
responseLoadedEventIdstracking to deduplicate. - Event edit form: Was fetching event data via a redundant PB call instead of using the team store’s already-loaded data.
- Player detail page: Team badges weren’t showing — resolved by using
userData.allTeamsinstead of a missing expand. - Gallery carousel: “Can’t change slide yet, too soon” error fixed with
slideDuration={0}.
What’s Next
The statistics page needs a rebuild to work with the new team-owns-players relationship. The current stats views still reference the old players.teams field.
This is the fifth post in the KnowMore series. For the architecture overview, see Building KnowMore - Architecture of a Privacy-First Sports Calendar. For the real-time data system, see KnowMore Real-Time Data Loading.