15 KiB
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_agentscollection (pbc_2929550049):{ player → players, tournament → tournaments, phone }. Created inpb_migrations/1758388728_created_free_agents.js, tournament relation added in1758402128_updated_free_agents.js. No partner/status field, no claim concept.- Service:
enrollFreeAgent/unenrollFreeAgent/getFreeAgentsinsrc/lib/pocketbase/services/tournaments.ts(~L189–216);transformFreeAgentinsrc/lib/pocketbase/util/transform-types.tsreturns{ 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) andenrolled-free-agent.tsx(the pool view). Regional agents can't see the pool at all — theisRegionalbranch 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.tsx→useGenerateRandomTeams/useConfirmTeamAssignments→generateRandomTeams(L146) andconfirmTeamAssignments(L326) insrc/features/tournaments/server.ts. Confirm finds-or-creates aprivate: trueteam per pair,enrollTeams it, thenunenrollFreeAgents both players. - The two-sided confirm precedent:
reportMatchScore/confirmMatchScoreinsrc/features/matches/server.ts(~L828–928). 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
PlayerStats—src/features/players/types.tsL34). - 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 (
enrollFreeAgentis 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
- Claim: agent A taps Claim on agent B. A's
free_agentsrow getsclaimed = <B's row id>. A can have one outgoing claim at a time (withdraw to switch). - 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.
- Accept → server verifies A's claim still points at B, then creates the team (reusing the
confirmTeamAssignmentsfind-or-create logic), enrolls it in the tournament, andunenrollFreeAgents both — both rows vanish from the pool, exactly like the admin flow. - Decline → clears A's
claimedfield. A is free to claim someone else. (Optionally notify A — see tunables.) - 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
reportMatchScoreauto-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 fieldclaimed(relation →free_agents, maxSelect 1) — "this agent's outgoing claim". Everything derives from it: my outgoing claim = my row'sclaimed; my incoming claims = rows whereclaimed = <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 thematchesrecord, 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_claimscollection{ 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. Setsclaimed. If target already claims caller → run the accept path (auto-match). Emitstournament; push to target.withdrawPartnerClaim({ tournamentId })— clears caller'sclaimed. Emitstournament.respondToPartnerClaim({ tournamentId, fromAgentId, accept: boolean })— verifiesfromAgent.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 +enrollTeamblock fromconfirmTeamAssignments(L341–381) into a shared helper (e.g.createOrReuseTeamForPair(player1Id, player2Id, teamName, tournamentId)), call it, thenunenrollFreeAgentboth (which drops both rows and any pointers). Emitteam+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
tournamentevent (src/lib/events/emitter.tsL25). The handler insrc/hooks/use-server-events.ts(L51) already invalidates the['tournaments']prefix, which coverstournamentKeys.free_agents(id)(['tournaments','free_agents',id],src/features/tournaments/queries.tsL9) — no new event type needed; the board updates live as claims land. - Push:
sendPushToPlayerinsrc/lib/push/index.ts— its doc comment already reserves this exact seam ("a free-agent claim involving the player"). Payloads (PushPayloadinsrc/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).
- On claim → target:
Regional visibility gate (relaxed)
Delete the regional "you're enrolled, wait for assignment" dead-end in enrolled-free-agent.tsx (L45–74): 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, soassign-partners.tsxandgenerateRandomTeamssee only the leftovers — zero changes to the pairing math. The even-count and ≥2 guards (L156–162) 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'sunenrollFreeAgentsweep clears all claim state. Optional nicety (cheap, do it):generateRandomTeamsseeds 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-partnersheader need no changes; they read the sameuseFreeAgentsquery.
Design system
- Mantine 8 + the app theme (
src/lib/mantine/mantine-provider.tsx):defaultRadius: "sm", dynamicprimaryColor. UseCard 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
Claimbutton. Waiting/pending states arevariant="subtle"/c="dimmed"; Accept is filled, Decline isvariant="subtle" color="red"(matching the enroll sheet's Cancel). - Press feedback via the
flxn-presstheme; motion only where it earns it — a single 120–350ms 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.tsxat 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.jsadding theclaimedself-relation (collectionIdpbc_2929550049, maxSelect 1, required false, cascadeDelete false). Mimic1758402128_updated_free_agents.js(plainmigrate((app)=>{...})with up + down). Players collection id ispbc_3072146508, tournamentspbc_340646327, teamspbc_1568971955. Collections have null rules; all access is server-side viapbAdmin. - Service layer: extend
createTournamentsServiceinsrc/lib/pocketbase/services/tournaments.ts—setFreeAgentClaim(agentId, targetAgentId|null),getFreeAgentByPlayer(playerId, tournamentId)(the lookup already inlined inunenrollFreeAgentL198), and makeunenrollFreeAgentalso clear inbound pointers (getFullList({ filter: 'claimed = "<rowId>"' })→ clear each). UpdatetransformFreeAgentinsrc/lib/pocketbase/util/transform-types.tsto surfaceclaimedAgentId. - Server fns: the three fns above in
src/features/tournaments/server.ts, next toenrollFreeAgent. ExtractcreateOrReuseTeamForPairfromconfirmTeamAssignments(L341–381) as a module-level helper and call it from both places — do not fork the team-creation logic. EmitemitServerEvent({ type: "tournament", tournamentId })(and{ type: "team" }on match) exactly like the existing fns; firesendPushToPlayer(src/lib/push/index.ts) after the write, non-blocking (.catch(log)), never failing the request. - Client data: hooks in
src/features/tournaments/hooks/followinguse-enroll-free-agent.ts(usesuseServerMutationfromsrc/lib/tanstack-query/hooks, invalidatestournamentKeys.free_agents(id)). Board readsuseFreeAgents+getAllPlayerStats/getAllEarnedBadgesvia new bulk queries keyed likesrc/features/players/queries.ts/src/features/badges/queries.ts. No new SSE event type:use-server-events.ts'stournamenthandler already invalidates the['tournaments']prefix. - UI: rework
enrolled-free-agent.tsxinto aFreeAgentBoard(new file under the same dir; keepunenroll-free-agent.tsxat 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.erroronly. "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.tsxsheet 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 viaflxn-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: trueteam appears on the tournament, both agents leave the pool, andassign-partnersshows 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
updatedage (e.g. 24h) checked lazily ingetFreeAgents. - 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.