Files
flxn-app/docs/free-agents.md
yohlo 22c282d8fa
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m49s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 30s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 9m40s
more for the overhaul
2026-07-22 16:41:44 -07:00

15 KiB
Raw Permalink Blame History

Free-Agent Matchmaking Board — Design Spec (v1, locked)

Status: design locked, not yet built. Extends the existing free-agent enrollment flow; the admin random-pairing flow stays as the fallback for anyone unmatched.

Players who sign up without a team ("free agents") can see each other — with stats and badges — and claim a partner. The claimed player must confirm (two-sided handshake, mirroring the score-report Confirm pattern). On mutual accept the app creates the team for them. The admin still arbitrates leftovers.

What exists today (baseline)

  • free_agents collection (pbc_2929550049): { player → players, tournament → tournaments, phone }. Created in pb_migrations/1758388728_created_free_agents.js, tournament relation added in 1758402128_updated_free_agents.js. No partner/status field, no claim concept.
  • Service: enrollFreeAgent / unenrollFreeAgent / getFreeAgents in src/lib/pocketbase/services/tournaments.ts (~L189216); transformFreeAgent in src/lib/pocketbase/util/transform-types.ts returns { id, phone, player }.
  • Server fns in src/features/tournaments/server.ts: getFreeAgents (L109, any authed user — note it currently returns phones for everyone), enrollFreeAgent (L116), unenrollFreeAgent (L131). All emit { type: "tournament", tournamentId }.
  • UI: src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx (enroll sheet) and enrolled-free-agent.tsx (the pool view). Regional agents can't see the pool at all — the isRegional branch shows only your own card with "Partners will be randomly assigned". Non-regional agents see a plain name + phone list. No stats, no badges, no actions.
  • Admin fallback: src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsxuseGenerateRandomTeams / useConfirmTeamAssignmentsgenerateRandomTeams (L146) and confirmTeamAssignments (L326) in src/features/tournaments/server.ts. Confirm finds-or-creates a private: true team per pair, enrollTeams it, then unenrollFreeAgents both players.
  • The two-sided confirm precedent: reportMatchScore / confirmMatchScore in src/features/matches/server.ts (~L828928). Pending state lives on the record itself (reported_by_team, reported_home_cups, …), the other side confirms, and a cross-report with identical values auto-finalizes.

The board

Replaces the plain list inside enrolled-free-agent.tsx for both regional and mainline tournaments (the regional visibility gate is relaxed — see below). Each agent renders as a card:

  • PlayerAvatar + name.
  • Stats strip: matches, win %, avg cups/match (from PlayerStatssrc/features/players/types.ts L34).
  • Badges row: the player's earned badges, compact (icon row, overflow "+N").
  • Action: one of Claim / Claimed — waiting (yours, with Withdraw) / Accept · Decline (they claimed you) / a subtle "pending with someone else" state.
  • Phone: keep today's behavior for mainline (copy-to-clipboard affordance); regionals stay phone-less (enrollFreeAgent is already called with "" phone via the regional path, and admin-enroll at L737 passes "").

Fetch stats/badges in bulk — getAllPlayerStats (src/features/players/server.ts L174) and getAllEarnedBadges (src/features/badges/server.ts, keyed in src/features/badges/queries.ts) — filter client-side to the agents on the board. Do not issue per-agent getPlayerStats/getPlayerBadges calls (N+1).

The handshake

  1. Claim: agent A taps Claim on agent B. A's free_agents row gets claimed = <B's row id>. A can have one outgoing claim at a time (withdraw to switch).
  2. B sees it (SSE-refreshed): "A wants to partner with you" with Accept / Decline. B can hold multiple incoming claims; accepting one implicitly declines the rest.
  3. Accept → server verifies A's claim still points at B, then creates the team (reusing the confirmTeamAssignments find-or-create logic), enrolls it in the tournament, and unenrollFreeAgents both — both rows vanish from the pool, exactly like the admin flow.
  4. Decline → clears A's claimed field. A is free to claim someone else. (Optionally notify A — see tunables.)
  5. Symmetry shortcut: if B already has an outgoing claim on A when A claims B (or vice versa), that is mutual intent — auto-accept immediately, mirroring how reportMatchScore auto-finalizes on an identical cross-report.

Team naming: default "<A first> & <B first>", same shape the random generator produces; rename later via the existing team-edit flow.

Data model (decided: Option A — field on free_agents)

Two options were weighed:

  • Option A (chosen): a self-relation on free_agents. Add one nullable field claimed (relation → free_agents, maxSelect 1) — "this agent's outgoing claim". Everything derives from it: my outgoing claim = my row's claimed; my incoming claims = rows where claimed = <my row id>; decline = clear the field; match = both rows deleted. No status enum needed for v1 (one outgoing claim, terminal states delete rows). This mirrors the score-report precedent where pending state lives on the matches record, and unenroll/delete automatically cleans up outgoing claims (incoming pointers to a deleted row are cleared server-side on unenroll — one filtered update).
  • Option B (rejected for v1): a partner_claims collection { tournament, from_agent, to_agent, status: pending|accepted|declined, created }. Buys an audit trail, decline history (anti-re-spam), and native multi-claim — at the cost of a new collection, cross-record consistency, and cleanup hooks on unenroll. Revisit if tunables land on "multiple outgoing claims" or "declines block re-claiming".

getFreeAgents grows to expand claimed and return { id, phone, player, claimedAgentId }; the board derives the four card states from that plus "who points at me".

Server fns (new, in src/features/tournaments/server.ts)

All player-facing: createServerFn().validator(zod).middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]).handler(...) + toServerResult, resolving the caller via pbAdmin.getPlayerByAuthId(context.userAuthId) — copy enrollFreeAgent (L116) as the shell and the guard style of confirmMatchScore.

  • proposePartner({ tournamentId, targetAgentId }) — caller must be an enrolled agent with no outgoing claim; target must be a different agent in the same tournament. Sets claimed. If target already claims caller → run the accept path (auto-match). Emits tournament; push to target.
  • withdrawPartnerClaim({ tournamentId }) — clears caller's claimed. Emits tournament.
  • respondToPartnerClaim({ tournamentId, fromAgentId, accept: boolean }) — verifies fromAgent.claimed === callerAgent.id (claim may have been withdrawn — fail with a friendly error). Decline: clear it. Accept: reuse the team path — extract the find-or-create-private-team + enrollTeam block from confirmTeamAssignments (L341381) into a shared helper (e.g. createOrReuseTeamForPair(player1Id, player2Id, teamName, tournamentId)), call it, then unenrollFreeAgent both (which drops both rows and any pointers). Emit team + tournament; push to the claimer.

Race safety: re-read both rows inside the handler before mutating (same optimistic pattern as score confirm — no locks, just verify-then-act; PB writes are serialized enough at this app's scale, per the existing flows).

Realtime + push

  • SSE: reuse the existing tournament event (src/lib/events/emitter.ts L25). The handler in src/hooks/use-server-events.ts (L51) already invalidates the ['tournaments'] prefix, which covers tournamentKeys.free_agents(id) (['tournaments','free_agents',id], src/features/tournaments/queries.ts L9) — no new event type needed; the board updates live as claims land.
  • Push: sendPushToPlayer in src/lib/push/index.ts — its doc comment already reserves this exact seam ("a free-agent claim involving the player"). Payloads (PushPayload in src/lib/push/types.ts):
    • On claim → target: { title: "You've been claimed!", body: "<Name> wants to be your partner for <tournament>", url: "/", tag: "partner-claim-<agentId>" }.
    • On accept → claimer: { title: "It's a match", body: "<Name> accepted — you're teammates", ... }.
    • Decline: no push (avoid rejection stings; the cleared state shows on the board).

Regional visibility gate (relaxed)

Delete the regional "you're enrolled, wait for assignment" dead-end in enrolled-free-agent.tsx (L4574): regionals get the same board (stats, badges, claim) — that self-card + "Partners will be randomly assigned when enrollment closes" collapses into the board's header copy ("Claim a partner, or the admin pairs whoever's left"). Keep the regional enroll sheet copy in enroll-free-agent.tsx but update it to mention claiming. Phone stays hidden for regionals (the claim button replaces its purpose).

Admin fallback interplay

  • Accepted handshakes remove both agents from free_agents, so assign-partners.tsx and generateRandomTeams see only the leftovers — zero changes to the pairing math. The even-count and ≥2 guards (L156162) still apply to what remains.
  • Pending (unaccepted) claims are invisible to the random pairer — it may split a pending pair. Acceptable for v1: random pairing runs at deadline, and confirmTeamAssignments's unenrollFreeAgent sweep clears all claim state. Optional nicety (cheap, do it): generateRandomTeams seeds mutually-irrelevant pairs randomly but keeps any pending claim pair together when both are still in the pool — a one-pass pre-grouping before the shuffle.
  • Admin's enrolled-players count and assign-partners header need no changes; they read the same useFreeAgents query.

Design system

  • Mantine 8 + the app theme (src/lib/mantine/mantine-provider.tsx): defaultRadius: "sm", dynamic primaryColor. Use Card withBorder radius="md" p="md" like the existing regional self-card so the board matches its neighbors.
  • Accent discipline: one accented element per card — the Claim button. Waiting/pending states are variant="subtle"/c="dimmed"; Accept is filled, Decline is variant="subtle" color="red" (matching the enroll sheet's Cancel).
  • Press feedback via the flxn-press theme; motion only where it earns it — a single 120350ms iOS-eased transition when a card flips state (claimed → matched), no confetti, no bouncing.
  • Badges row reuses the visual language of src/features/badges/components/badge-showcase.tsx at small size — don't invent a new badge chip.

Implementation notes for a fresh agent (build this cold)

Context: worktree social branch; changes are typically left uncommitted for review. Read docs/free-agents.md (this file) fully first, then:

  • Migration: one new pb_migrations/*_updated_free_agents.js adding the claimed self-relation (collectionId pbc_2929550049, maxSelect 1, required false, cascadeDelete false). Mimic 1758402128_updated_free_agents.js (plain migrate((app)=>{...}) with up + down). Players collection id is pbc_3072146508, tournaments pbc_340646327, teams pbc_1568971955. Collections have null rules; all access is server-side via pbAdmin.
  • Service layer: extend createTournamentsService in src/lib/pocketbase/services/tournaments.tssetFreeAgentClaim(agentId, targetAgentId|null), getFreeAgentByPlayer(playerId, tournamentId) (the lookup already inlined in unenrollFreeAgent L198), and make unenrollFreeAgent also clear inbound pointers (getFullList({ filter: 'claimed = "<rowId>"' }) → clear each). Update transformFreeAgent in src/lib/pocketbase/util/transform-types.ts to surface claimedAgentId.
  • Server fns: the three fns above in src/features/tournaments/server.ts, next to enrollFreeAgent. Extract createOrReuseTeamForPair from confirmTeamAssignments (L341381) as a module-level helper and call it from both places — do not fork the team-creation logic. Emit emitServerEvent({ type: "tournament", tournamentId }) (and { type: "team" } on match) exactly like the existing fns; fire sendPushToPlayer (src/lib/push/index.ts) after the write, non-blocking (.catch(log)), never failing the request.
  • Client data: hooks in src/features/tournaments/hooks/ following use-enroll-free-agent.ts (uses useServerMutation from src/lib/tanstack-query/hooks, invalidates tournamentKeys.free_agents(id)). Board reads useFreeAgents + getAllPlayerStats / getAllEarnedBadges via new bulk queries keyed like src/features/players/queries.ts / src/features/badges/queries.ts. No new SSE event type: use-server-events.ts's tournament handler already invalidates the ['tournaments'] prefix.
  • UI: rework enrolled-free-agent.tsx into a FreeAgentBoard (new file under the same dir; keep unenroll-free-agent.tsx at the bottom). Card = PlayerAvatar + name + stats strip + badge row + action slot. Confirm-style incoming-claim UI: filled Accept, subtle red Decline, same layout rhythm as the score-confirm affordance in the match card. Sheets via @/components/sheet/sheet + useSheet, buttons via @/components/button.
  • Toasts: import { toast } from "@/lib/sonner"toast.success / toast.error only. "Claim sent", "You're teammates!", and a friendly error when a claim was withdrawn/taken ("They just partnered up — pick someone else").
  • Copy updates: enroll-free-agent.tsx sheet text (both branches) now mentions the board + claiming; delete the regional-only self-card branch in the pool view.
  • Design system: Mantine 8 + the app theme; defaultRadius: "sm"; press feedback via flxn-press; one accent per card; match neighboring components, don't invent a new look.
  • Verify: enroll two players as free agents (one regional tournament, one mainline), claim, accept — confirm a private: true team appears on the tournament, both agents leave the pool, and assign-partners shows only leftovers. Then run the admin random flow on an even leftover pool to prove the fallback is untouched.

Tunables / open questions

  • Multiple outgoing claims? v1: one at a time (keeps Option A trivially consistent). If demand appears, move to Option B's collection.
  • Claim expiry? v1: none — claims live until withdrawn, declined, or the pool is swept. Candidate: auto-expire via updated age (e.g. 24h) checked lazily in getFreeAgents.
  • Does admin auto-pair leftovers at a deadline? Today it's manual (admin opens assign-partners and generates). Keep manual for v1; a scheduled auto-pair is a separate feature.
  • Decline memory: should a declined claimer be blocked from re-claiming the same person? v1: no (rows carry no history). Option B enables it.
  • Keep pending pairs together in random pairing? Recommended-cheap, but genuinely optional — cut it if it complicates the shuffle.
  • Phone visibility: mainline keeps phones on the board for now; consider hiding until matched once claiming proves out.
  • Push copy/tag collapsing: tag: "partner-claim-<agentId>" collapses repeat claims from the same person; revisit if it feels spammy.