KnowMore v0.4.1 - Watch Consolidation, Team Ownership, and Form Overhaul

Published on Feb 14, 2026

KnowMore v0.4.1 - Watch Consolidation, Team Ownership, and Form Overhaul

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

FieldCollectionWhy
immich_api_keyusers, _superusersMoved to users_immich_keys — prevents key leaking via expands
players.clubsplayersSingle-club architecture — redundant
teams.clubteamsSingle-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: createNotificationWithRecipients was 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 responseLoadedEventIds tracking 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.allTeams instead 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.