PlayMore Spring 2026 Update

Published on Apr 22, 2026

PlayMore Spring 2026 Update

A dev-log on what shipped on feature/group-knockout since v0.3.0 — roughly 100 commits, all architectural work, no merge to master yet. Written for myself so future me can reconstruct the intent behind decisions that look arbitrary in the diff.

Table of Contents

What Changed

git log --oneline a0f4ccf..HEAD covers the range. Grouping by theme:

ThemeCommits (representative)Status
Status machine + pause-modeeac60d9, f758c59, 9ce6143, 12831af, 657d5cdShipped
Metadata-first creationd341339, 9d45bf5Shipped
Pre-commit match editora201150, f8d732fShipped (group + knockout only)
Rounds-view redesign38c9cb1 … 2e0cb27 (10+ commits)Shipped
Group + knockout extractiond3e81fb, 24993c8, 2509cc8, 74f644b, 806e7ed, 0cbd49eShipped
User-owned teams + signupsd341339, 7a7712a, 697eae0, 5565161, 8ca1d21, cb02812Backend done, frontend 95%
Backend security fixes124806b, 4bae1a5Shipped today

Architectural audit with verdicts and evidence: docs/reports/2026-04-22-architecture-audit.md.

1. Status Machine with Pause-Mode

Before this branch the enum was draft | active | completed | cancelled | break. That worked as long as “start the tournament” and “start the clock” were the same moment. The group + knockout format made them different: the start wizard generates the full schedule, but the organiser often wants to delay the actual start until players and refs have arrived. And once live, weather delays are real.

New states from migration pb/pb_migrations/1776886800_updated_tournaments_pause_lifecycle.go:

Three new columns on tournaments:

  • started_at — timestamp of the actual Start Now press
  • paused_at — currently-paused-since timestamp, cleared on resume
  • pause_offset_minutes — accumulated paused duration across cycles

The effective tournament clock is now - started_at - pause_offset_minutes. Match UI uses this helper (src/lib/utils/tournaments/effective-time.ts, 12831af) so every round header, every “LIVE” badge, every round-time readout reflects pauses without any cascading recomputation. The commit 657d5cd fixed a subtle Svelte reactivity bug where the offset was computed inside a nested {@const} in the template — the compiler flagged the tournament prop as “only captures the initial value”. Lifting the computation to a script-level $derived.by fixed it.

Start wizards now leave tournaments in ready (not active) per f758c59. A <RequireRole role="superuser">-style admin gate isn’t right here — any tournament organiser should be able to start/pause their own tournament — so the buttons are gated on authState.isOwner(tournament.users) || isAdmin.

UX note

Start Now uses a shadcn AlertDialog with three actions: Cancel / No (keep planned date — every match time shifts by the delta) / Yes (update tournament.date to now — schedule reads from the new baseline). The “No” branch is important: if an organiser had a 10:00 start on the books and presses Start Now at 10:07, they almost certainly want the 7-minute shift applied to every match, not a reset to 10:07 baseline.

2. Metadata-First Creation

Before: /tournaments/create was eight per-format wizards — PMF G, F, E, knockout single, knockout double, group + knockout, custom variants — each with its own schema, its own step components, its own “pick clubs” UI. Commit d341339 deleted about 7700 lines of this.

After:

  1. /tournaments/create is a five-field form: name, date, time, level, description. It produces a format = null draft.
  2. On the detail page, a FormatPicker component PATCHes the tournament with format + format-specific fields (category, clubType, host, canton, clubs) in one write.
  3. /edit/metadata (9d45bf5) lets an organiser fix typos or reschedule a draft without committing to a format first.

The store side of this is DraftTournamentStore.svelte.ts — a format-agnostic store that returns empty arrays for matches/standings/bracket, throws on match operations (explicit “this isn’t valid yet” instead of silent no-ops), and subscribes to the tournament_signups collection for the detail-page signup UI. When the tournament transitions draft → ready, the start wizard navigates with a full reload so the layout factory runs again and swaps DraftTournamentStore out for PMFTournamentStore / KnockoutTournamentStore / GroupKnockoutStore. No in-place store swap, no subscription race.

Why this matters architecturally

The old model made format a “gift-wrapped decision” — you picked one on screen 1 and lived with it. It also meant any tournament-related route assumed a format. Draft-as-a-real-state reflects how organisers actually work: “I know there’s a tournament on May 3, I don’t yet know if it’s PMF or group + knockout, give me a record to come back to.”

This was necessary scaffolding for the signup flow (§6). Without metadata-first drafts, “tournament exists but teams haven’t been added” isn’t a legitimate state to share with potential participants.

3. Pre-Commit Match Editor

a201150 added MatchEditorList.svelte — a match-by-match editable list that shows up in the group + knockout start wizard before the matches are persisted. Drag-to-reorder via sortablejs (SortableJS action in src/lib/actions/sortable.ts); amber warning badges for team conflicts (same team twice in a round) and field-count overflow.

The safety trick is that generatedMatches lives in $state on the wizard, not in the DB. Relational fields like next_match_winner / next_match_loser aren’t populated yet — those get wired up on final commit. Dragging a match to a different round updates the match’s round + match_order + field locally; if you change your mind, you close the wizard. Nothing to undo.

f8d732f layered a small structural change: 3rd-place matches moved out of the Final round into their own round that plays between bracket stages. Two reasons:

  1. Teams eliminated in the semi-final shouldn’t wait through the final to play their consolation match.
  2. The teams in the final benefit from the buffer — they get 20+ minutes of rest while 3rd place is decided.

For now the editor is only wired into the group + knockout wizard. Other wizards (PMF, knockout single/double) can adopt the same component — no cross-format coupling.

4. Rounds-View Redesign

Commits 38c9cb1 through 2e0cb27 — about ten iterative commits, mostly visual, no store or PB changes. The pattern worth documenting:

Match card, old: variable-height rows, actions in an overflow dropdown, inconsistent alignment across fields.

Match card, new: fixed three-row structure — meta line (round + field + time) / scoreboard (team-name / score / team-name, stacked on narrow viewports) / actions (icon bar top-right on expanded, bottom-right on compact).

Other polish:

  • Compact mode — localStorage['tournament:compactMode'] toggle, $state in the page component. Big list of matches collapses to a single row each.
  • Team highlight — click any team name and every match that team appears in gets a primary-colour ring. Toggle off by clicking again. Tournament-wide $state, CSS-only styling.
  • Scroll-hide chrome — top tabs + bottom nav collapse when you scroll down, reappear when you scroll up. New scrollHide action in src/lib/actions/scrollHide.ts. Mobile-only UX.
  • Time-only match display on mobile — the date is redundant inside a single-day tournament.
  • Segmented-control round-scope toggle — a single icon toggle instead of a row of buttons (commits 7481f7d, b43895b).
  • Subtle tab indicator — thin primary line on top of the active tab instead of a filled background (9bc078f).

Nothing architectural, but worth cataloguing because the UX is a big part of what makes the app usable tournament day.

5. Group + Knockout Extraction

d3e81fb extracted GroupKnockoutStore from KnockoutTournamentStore. The parent dropped from ~2180 lines to 1750; the subclass is 760 lines.

The inheritance choice was deliberate. Group + knockout is a knockout tournament with a group phase tacked on the front. It shares:

  • Team-record plumbing
  • Watcher setup (tournament + matches + teams)
  • Knockout match advancement logic
  • Bracket rendering primitives

It overrides:

  • capabilities — shows Groups tab, hides standalone league table
  • standings — aggregates group-phase results differently
  • bracketStructure — groups feed bracket slots via bracket_position
  • updateMatch — group-phase results trigger group-standings recalc before bracket propagation

Child commits after the extraction fixed real bugs:

  • 2509cc8 — cap knockout rounds at fieldCount so no round has more parallel matches than fields available (Vikunja #294)
  • 74f644b — pack parallelizable matches across fields to maximise utilisation
  • 806e7ed — reorder rounds so placement matches play between bracket stages (the rest-buffer pattern from §3)
  • 869dca6 — placement-round winners wrongly advancing to the Final; getRoundMatchLabel now keys off bracket_position consistently set by every generator path
  • 08c548c — reseat + cascade-reset when group results change (editing a group-phase score after the bracket has seeded must reseed the bracket and clear any downstream results)

One outstanding TODO: GroupKnockoutStore.svelte.ts:106 hardcodes return 2; for max_groups because tournament_configs doesn’t yet carry that field. Low risk, non-breaking when the schema gets there.

6. User-Owned Teams + Signup Flow

Two independent changes that compose into one feature.

teams.created_by + ownership model

Migration 1776774400_updated_teams_created_by.go adds a nullable created_by FK on teams pointing to users. A PB hook auto-stamps it from auth on create (spoof-proof). Teams now come in three flavours distinguished by which relation is set:

  • Swiss team — club set, tournament null, created_by null
  • User-owned team — created_by set, tournament null, club null
  • Tournament-scoped slot — tournament set, cascade-deletes with the tournament

/profile/teams is the personal CRUD page. Collection rules: list/view open to authenticated users (team names aren’t sensitive and tournament participants need to look them up); create open to any authenticated user (hook auto-stamps created_by); update/delete locked to the owner.

tournament_signups collection + Go hook

pb/pb_migrations/1776786000_created_tournament_signups.go creates the collection: tournament, team, user, status (pending|approved|rejected), message. Unique index on (tournament, team) prevents duplicate requests.

pb/signups/signups.go has two hooks:

  1. Create-guard — stamps user from auth, forces status = "pending" (client can’t self-approve), validates the target tournament is status = "draft" and clubType = "custom".
  2. Status-sync — on pending → approved, idempotently adds the team to tournament.participating_teams. On approved → rejected, idempotently removes it. Uses e.Record.Original() to detect the transition (canonical v0.23+ pattern).

The reason for mediation: a regular user (no organiser rights) can’t PATCH tournaments.participating_teams directly because the updateRule is organiser-only. Without this hook the user couldn’t sign up at all. With it, they create a tournament_signups record instead; the organiser reviews and approves; the hook writes to participating_teams with server-side authority.

Vikunja #302 tracks the remaining frontend work — TournamentSignup.svelte still PATCHes the tournament directly in some paths, and the organiser approval UI (SignupReviewSection.svelte) isn’t finished. Backend is ready.

7. Backend Security Fixes (Today)

Two quick ones from today’s audit:

fix(auth): require verified email to create tournaments (124806b)

tournaments.createRule was @request.auth.id != "" — any authenticated user, verified or not. The frontend disabled the Create button for unverified users, but in a SPA a button is a hint. Anyone with devtools could call pb.collection('tournaments').create() directly.

Migration 1776888400 tightens to:

@request.auth.id != "" && @request.auth.verified = true

Superusers bypass collection rules automatically, so admin flows keep working. The skip_email_verification app_info flag continues to work transparently — it sets verified=true on new users, so the rule still passes. The frontend gained a requireVerified() guard in src/lib/guards/routeGuards.ts that throws a 403 at the load-function level for fast UX fail.

fix(matches): multi-organiser updates use ~ instead of = (4bae1a5)

matches.createRule / updateRule / deleteRule used:

@request.auth.id = tournament.users.id

tournament.users is a multi-value relation. With =, the rule only matched when users held exactly one id (string coercion of the array). Co-organisers got silent 403s on every match-result PATCH. Changed to ~ (contains). The tournaments collection itself already did this correctly (line 1229 of the snapshot uses users.id ~ @request.auth.id) — only matches needed fixing.

8. Type-Check Baseline

pnpm check reports 153 errors / 55 warnings as of today’s HEAD. Per the session handoff, baseline was 152 / 54 — so 1 error and 1 warning were added this branch, both in the uncommitted state at hand-off time and now merged (657d5cd). The 150+ pre-existing errors are all in src/routes/showcase/* (demo routes using stub match/team shapes) and src/routes/timeline/+page.svelte (a hasComponents property missing on the Release type). Clearing these is a separate task.

9. Merge Readiness + Follow-Ups

Ready to merge to master after:

  • Vikunja #302 — decide whether to finish the signup frontend now or track as a follow-up
  • Release notes — write changelog/releases/v0.4.0.md (or v0.3.1 if we ship this as a patch)
  • Smoke test — the 2-field / 2-group / 4-teams config the branch was last tested on

Non-blocking cleanup:

  • Document the /timeline/+page.server.ts SSR exception in CLAUDE.md (one page, deliberate, shouldn’t be mistaken for a regression)
  • GroupKnockoutStore.svelte.ts:106 max_groups hardcoded — fix once tournament_configs schema gains the field
  • Consider extracting BracketGenerator out of KnockoutTournamentStore (1750 lines is getting close to god-class)
  • matches.updateRule fix is in, but a regression test for multi-organiser would prevent the next = vs ~ bug

Notes written 2026-04-22. Branch: feature/group-knockout @ b7253f0.