Draw/Match Management — API Contracts & Screen Flow¶
Owner: Morgan (Solution Architect) Status: Implementation blueprint Last updated: 2026-04-12
1. tRPC Router Structure¶
The existing root.ts mounts three routers: health, signage, tournament. This design adds four new routers and extends one existing router. All new routers use protectedProcedure unless noted otherwise.
Updated root.ts¶
export const appRouter = router({
health: healthRouter,
signage: signageRouter, // existing — extended with draw + bracket queries
tournament: tournamentRouter, // existing — no changes
draw: drawRouter, // NEW
player: playerRouter, // NEW
registration: registrationRouter, // NEW
match: matchRouter, // NEW
});
1.1 draw Router¶
Manages draws within a tournament (ENTITY-003).
draw.list — query¶
input: z.object({
tournamentId: z.string().uuid(),
})
output: Array<{
id: string;
name: string;
type: "SINGLE_ELIMINATION" | "ROUND_ROBIN";
drawFormatId: string; // "F1".."F8"
drawFormatName: string; // joined from draw_formats
isLoserDraw: boolean;
registrationCount: number; // count of registrations for this draw
matchCount: number; // count of matches for this draw
}>
DB: SELECT draws.*, draw_formats.name, COUNT(registrations), COUNT(matches) FROM draws LEFT JOIN ... WHERE draws.tournament_id = $1 AND draws.is_loser_draw = false GROUP BY draws.id.
draw.byId — query¶
input: z.object({
id: z.string().uuid(),
})
output: {
id: string;
tournamentId: string;
name: string;
type: "SINGLE_ELIMINATION" | "ROUND_ROBIN";
drawFormatId: string;
drawFormat: DrawFormat; // full draw_formats row
isLoserDraw: boolean;
registrations: Array<{
id: string;
playerId: string;
playerFirstName: string;
playerLastName: string;
playerRankingCategory: string | null;
isSeeded: boolean;
qualifierWildcard: string | null;
}>;
matches: Array<Match>; // all matches in this draw, ordered by roundNumber, slotIndex
}>
DB: three queries in parallel — draw row + format join, registrations with player join, matches. Assembles in the procedure.
draw.create — mutation¶
input: z.object({
tournamentId: z.string().uuid(),
name: z.string().min(1).max(200),
type: z.enum(["SINGLE_ELIMINATION", "ROUND_ROBIN"]),
drawFormatId: z.string().min(1).max(10), // "F1".."F8"
})
output: Draw // the inserted row
DB: INSERT INTO draws. Validate drawFormatId exists in draw_formats — the FK constraint handles it, translate the FK violation into a tRPC BAD_REQUEST.
draw.update — mutation¶
input: z.object({
id: z.string().uuid(),
name: z.string().min(1).max(200).optional(),
type: z.enum(["SINGLE_ELIMINATION", "ROUND_ROBIN"]).optional(),
drawFormatId: z.string().min(1).max(10).optional(),
})
output: Draw
Guard: if the draw already has matches, reject changes to type and drawFormatId. Changing the scoring format after matches exist would invalidate stored results. Name-only changes are always safe.
draw.delete — mutation¶
DB: DELETE FROM draws WHERE id = $1. CASCADE deletes registrations and sets match draw_id to null. Warn: this is destructive. The frontend must confirm.
draw.generateBracket — mutation¶
This is the core bracket/group generation procedure. It reads the draw's registrations, applies seeding logic, and creates match rows.
input: z.object({
drawId: z.string().uuid(),
// Ordered list of player IDs representing the seeded order.
// Position 0 = seed 1 (top of bracket), position 1 = seed 2, etc.
// Players not in this list are unseeded and placed randomly.
seedOrder: z.array(z.string().uuid()),
})
output: {
matchesCreated: number;
// For RR: the group(s) generated. For SE: the bracket depth (rounds).
structure:
| { type: "SINGLE_ELIMINATION"; rounds: number; matchIds: string[] }
| { type: "ROUND_ROBIN"; playerCount: number; matchIds: string[] }
}
Guard: if the draw already has matches, reject. The organizer must delete existing matches first (via draw.clearBracket).
Server-side logic — Single Elimination:
- Read confirmed registrations for this draw.
- Determine bracket size: next power of 2 >= player count. Excess slots become byes.
- Place seeds using standard tennis seeding (seed 1 top, seed 2 bottom, seeds 3-4 in quarter positions, etc.).
- Fill remaining slots randomly.
- Create match rows with:
round_number: 1 (first round) through N (final).slot_index: position within the round (0-indexed).next_match_id: FK to the match in the next round. Round N match at slot S feeds into round N+1 match at slot floor(S/2).winner_slot:UPPERif slot is even,LOWERif slot is odd.player1_id/player2_id: populated for first-round matches; null for later rounds.round_label: auto-generated from round depth ("R64", "R32", "R16", "QF", "SF", "Final").- Process byes: if a first-round match has only one player, auto-advance that player to the next round immediately (set result to WALKOVER with the present player as winner, advance via
computeAdvancement).
Server-side logic — Round Robin:
- Read confirmed registrations.
- Generate all pairings: N players produce N*(N-1)/2 matches.
- Create match rows with:
round_number: round in the round-robin schedule (use circle method for balanced scheduling).slot_index: match position within the round.player1_id/player2_id: both populated.next_match_id/winner_slot: null (RR has no bracket advancement).round_label: "Round 1", "Round 2", etc.
All match inserts happen in a single transaction.
draw.clearBracket — mutation¶
DB: DELETE FROM matches WHERE draw_id = $1. This is the "reset" action if the organizer wants to re-generate after adjusting registrations or seeding.
Guard: if any match in the draw has status COMPLETED, reject unless a force: true flag is passed. Prevents accidental loss of entered results.
draw.standings — query¶
Round-robin standings computation. Returns live standings for a draw.
input: z.object({
drawId: z.string().uuid(),
})
output: StandingsResult // from @courtsidedesk/scoring
Server-side logic:
- Load the draw (must be ROUND_ROBIN, else throw BAD_REQUEST).
- Load all matches for the draw that have
status = COMPLETEDand a non-nullresult. - Load the draw format from
draw_formats. - Parse each match's
resultJSONB column viaparseMatchResult(). - Build
PoolMatch[]array with player IDs, parsed results, and format. - Call
computeStandings(matches, playerIds)from@courtsidedesk/scoring. - Return the
StandingsResult.
1.2 player Router¶
Manages the persistent player registry (ENTITY-005).
player.search — query¶
input: z.object({
query: z.string().min(1).max(100),
limit: z.number().int().min(1).max(50).default(20),
})
output: Array<{
id: string;
firstName: string;
lastName: string;
licenseNumber: string | null;
rankingCategory: string | null;
clubName: string | null;
}>
DB: SELECT ... FROM players WHERE last_name ILIKE $1 OR first_name ILIKE $1 OR license_number = $1 LIMIT $2. The ILIKE pattern is query% (prefix match), not %query% (too slow without trigram index, not needed at this scale).
player.create — mutation¶
input: z.object({
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
licenseNumber: z.string().max(20).optional(),
email: z.string().email().optional(),
phoneMobile: z.string().max(30).optional(),
rankingCategory: z.string().max(10).optional(),
rankingValue: z.number().optional(),
clubName: z.string().max(200).optional(),
})
output: Player
DB: INSERT INTO players. If licenseNumber is provided and already exists, translate the unique violation into tRPC CONFLICT with a message suggesting the organizer search for the existing player instead.
player.update — mutation¶
input: z.object({
id: z.string().uuid(),
firstName: z.string().min(1).max(100).optional(),
lastName: z.string().min(1).max(100).optional(),
licenseNumber: z.string().max(20).optional(),
email: z.string().email().nullable().optional(),
phoneMobile: z.string().max(30).nullable().optional(),
phoneHome: z.string().max(30).nullable().optional(),
phoneWork: z.string().max(30).nullable().optional(),
rankingCategory: z.string().max(10).nullable().optional(),
rankingValue: z.number().nullable().optional(),
clubName: z.string().max(200).nullable().optional(),
})
output: Player
player.listForTournament — query¶
Contact sheet backing query (SCR-003).
input: z.object({
tournamentId: z.string().uuid(),
})
output: Array<{
id: string;
firstName: string;
lastName: string;
phoneHome: string | null;
phoneWork: string | null;
phoneMobile: string | null;
email: string | null;
draws: string[]; // names of draws the player is registered in
}>
DB: SELECT players.*, array_agg(draws.name) FROM players JOIN registrations ON ... JOIN draws ON ... WHERE registrations.tournament_id = $1 GROUP BY players.id ORDER BY players.last_name ASC.
1.3 registration Router¶
Manages player-to-draw registrations (ENTITY-006).
registration.add — mutation¶
input: z.object({
playerId: z.string().uuid(),
tournamentId: z.string().uuid(),
drawId: z.string().uuid(),
isSeeded: z.boolean().default(false),
qualifierWildcard: z.enum(["Q", "WC", "LL"]).nullable().default(null),
})
output: Registration
DB: INSERT INTO registrations. Unique constraint on (player_id, draw_id) — translate violation to CONFLICT.
registration.remove — mutation¶
Guard: if the draw already has generated matches, warn but allow. The match rows reference player IDs directly, not registration IDs, so removing a registration does not corrupt the bracket. But the organizer should be aware.
registration.bulkAdd — mutation¶
For adding multiple players to a draw at once (e.g., after searching/selecting).
input: z.object({
tournamentId: z.string().uuid(),
drawId: z.string().uuid(),
playerIds: z.array(z.string().uuid()).min(1).max(128),
})
output: { added: number; skipped: number } // skipped = already registered
DB: INSERT INTO registrations ... ON CONFLICT (player_id, draw_id) DO NOTHING. Count inserted vs skipped.
registration.updateSeeding — mutation¶
Bulk update seeding order for a draw.
input: z.object({
drawId: z.string().uuid(),
seedings: z.array(z.object({
registrationId: z.string().uuid(),
isSeeded: z.boolean(),
qualifierWildcard: z.enum(["Q", "WC", "LL"]).nullable(),
})),
})
output: { updated: number }
1.4 match Router¶
The core match lifecycle router. Handles result entry, court assignment, and bracket advancement.
match.listByDraw — query¶
input: z.object({
drawId: z.string().uuid(),
})
output: Array<Match & {
player1: { id: string; firstName: string; lastName: string; rankingCategory: string | null } | null;
player2: { id: string; firstName: string; lastName: string; rankingCategory: string | null } | null;
}>
DB: SELECT matches.*, p1.*, p2.* FROM matches LEFT JOIN players p1 ON ... LEFT JOIN players p2 ON ... WHERE draw_id = $1 ORDER BY round_number ASC, slot_index ASC.
match.listByTournament — query¶
Returns all matches for SCR-005 (Court Assignment screen).
input: z.object({
tournamentId: z.string().uuid(),
// Optional filters:
status: z.enum(["PLANNED", "ON_COURT", "COMPLETED"]).optional(),
courtId: z.string().uuid().nullable().optional(), // null = unassigned
})
output: Array<Match & {
player1: PlayerSummary | null;
player2: PlayerSummary | null;
drawName: string | null;
}>
match.assignCourt — mutation¶
Drag-to-court action from SCR-005.
input: z.object({
matchId: z.string().uuid(),
courtId: z.string().uuid().nullable(), // null = unassign (drag back to panel)
plannedStartTime: z.string().datetime().nullable().optional(),
})
output: Match
DB: UPDATE matches SET court_id = $2, planned_start_time = $3, status = CASE WHEN $2 IS NULL THEN 'PLANNED' ELSE status END WHERE id = $1.
When courtId is null, also clear planned_start_time and reset status to PLANNED (unless COMPLETED).
Realtime: This write triggers Supabase Realtime on the matches table, which SCR-001 subscribes to.
match.setOnCourt — mutation¶
Marks a match as actively being played.
DB: UPDATE matches SET status = 'ON_COURT', actual_start_time = COALESCE(actual_start_time, now()) WHERE id = $1 AND status = 'PLANNED'. Reject if status is not PLANNED.
match.submitResult — mutation¶
This is the most complex mutation in the system. It validates the result, saves it, determines the winner, and advances the bracket — all in one transaction.
input: z.object({
matchId: z.string().uuid(),
result: matchResultSchema, // imported from @courtsidedesk/scoring
})
output: {
match: Match;
advancement: AdvancementIntent; // from @courtsidedesk/scoring
// For RR draws, recomputed standings after this result:
standings: StandingsResult | null;
}
Server-side logic (single Postgres transaction):
- Load match row with
FOR UPDATErow lock. - Guard: match must not already be COMPLETED. If it is, throw CONFLICT ("result already entered — use match.editResult to correct").
- Load draw to get
draw_format_idandtype. - Load DrawFormat from
draw_formatstable. - Validate result via
validateMatchResult(drawFormat, result)from@courtsidedesk/scoring. If validation fails, throw BAD_REQUEST with the structuredValidationIssue[]array in the error data (the frontend renders these as field-level errors on the result entry form). - Update match row:
result= the validated MatchResult as JSONB.winner=result.winner(PLAYER1 or PLAYER2).status=COMPLETED.- Bracket advancement (Single Elimination only):
- Call
computeAdvancement({ result, player1Id, player2Id, nextMatchId, winnerSlot }). - If
intent.kind === "advance": update the next match row, settingplayer1_id(ifwinnerSlot === "UPPER") orplayer2_id(ifwinnerSlot === "LOWER") tointent.winnerPlayerId. - Standings recomputation (Round Robin only):
- Load all completed matches in the draw.
- Parse all results via
parseMatchResult(). - Call
computeStandings(). - Return standings in the response (the frontend updates the standings table immediately).
- Commit transaction.
Realtime: The match update triggers Supabase Realtime. SCR-001 picks up the status change; SCR-002 picks up the result and (for SE) the advancement.
match.editResult — mutation¶
Corrects a previously submitted result. Same validation pipeline as submitResult, but also handles un-advancing the bracket if the winner changes.
input: z.object({
matchId: z.string().uuid(),
result: matchResultSchema,
})
output: {
match: Match;
advancement: AdvancementIntent;
standings: StandingsResult | null;
}
Server-side logic:
- Load match — must be COMPLETED.
- Check if winner changed compared to the stored result.
- If winner changed AND the match has
next_match_id: - Check if the next match already has a result. If so, throw CONFLICT — cannot change a winner whose advancement match is already completed. The organizer must edit results in reverse bracket order.
- Clear the advanced player from the next match (
player1_idorplayer2_iddepending onwinner_slot). - Recompute advancement with the new winner.
- Otherwise, same flow as
submitResultsteps 5-9.
match.clearResult — mutation¶
Removes a result from a match, resetting it to PLANNED or ON_COURT.
Guard: if the match fed a next-round match that already has a result, reject. Same reverse-order constraint as editResult.
DB: UPDATE matches SET result = NULL, winner = NULL, status = 'PLANNED' WHERE id = $1. If bracket advancement occurred, also clear the winner's slot on the next match.
match.reorderOnCourt — mutation¶
Updates planned start times for multiple matches on a court (after drag reorder).
input: z.object({
courtId: z.string().uuid(),
matchOrder: z.array(z.object({
matchId: z.string().uuid(),
plannedStartTime: z.string().datetime().nullable(),
})),
})
output: { updated: number }
DB: batch UPDATE in a single transaction.
1.5 signage Router (extended)¶
Add queries for the public draw view (SCR-002).
signage.draw — query (NEW, publicProcedure)¶
input: z.object({
drawId: z.string().uuid(),
})
output: {
draw: { id: string; name: string; type: string; drawFormatId: string };
matches: Array<{
id: string;
roundNumber: number | null;
slotIndex: number | null;
roundLabel: string | null;
status: string;
player1: PlayerSummary | null;
player2: PlayerSummary | null;
result: MatchResult | null; // parsed from JSONB
winner: string | null;
nextMatchId: string | null;
winnerSlot: string | null;
}>;
// For RR only:
standings: StandingsResult | null;
}
signage.schedule (EXTENDED)¶
Extend the existing stub to return courts with matches.
output: {
tournament: { id; slug; name; startDate; endDate };
courts: Array<{
id: string;
name: string;
sortOrder: number;
matches: Array<{
id: string;
status: string;
plannedStartTime: string | null;
actualStartTime: string | null;
player1: PlayerSummary | null;
player2: PlayerSummary | null;
drawName: string | null;
roundLabel: string | null;
result: MatchResult | null;
winner: string | null;
}>;
}>;
// Unassigned matches (court_id IS NULL):
unassigned: Array<MatchSummary>;
}
2. Screen Flow¶
The organizer flow after creating a tournament proceeds through these screens. Each screen corresponds to a route in the Next.js App Router under /organizer/[tournamentId]/....
Screen Map¶
/organizer
/[tournamentId]
/ -> Tournament Overview (dashboard)
/draws -> Draw List
/draws/new -> Create Draw form
/draws/[drawId] -> Draw Detail (registrations + bracket/group)
/draws/[drawId]/seed -> Seeding & Bracket Generation
/draws/[drawId]/results -> Result Entry (focused view)
/schedule -> Court Assignment (SCR-005)
/contacts -> Contact Sheet (SCR-003)
2.1 Tournament Overview — /organizer/[tournamentId]¶
Purpose: Landing page after selecting a tournament. Shows summary cards.
Content: - Tournament name, dates. - Card grid: one card per draw showing name, format, registration count, match progress (X/Y completed). - Quick-action buttons: "Manage Draws", "Court Schedule", "Contact Sheet".
Data: tournament.byId + draw.list({ tournamentId }).
2.2 Draw List — /organizer/[tournamentId]/draws¶
Purpose: CRUD for draws within the tournament.
Content:
- Table of draws: Name, Type (SE/RR), Format (F1-F8 label), Registrations count, Matches count, Status indicator.
- "Create Draw" button -> navigates to /draws/new.
- Row click -> navigates to /draws/[drawId].
Data: draw.list({ tournamentId }).
2.3 Create Draw — /organizer/[tournamentId]/draws/new¶
Purpose: Form to create a new draw.
Fields:
1. Name — free text input. Example placeholder: "WS 30+ R7/R9".
2. Type — radio or select: Single Elimination / Round Robin.
3. Scoring Format — select dropdown populated from the draw_formats catalogue (F1-F8). Each option shows the name, e.g., "F2 — Standard, Tiebreak All Sets". The dropdown groups by bestOf for clarity.
Submit: Calls draw.create. On success, navigates to /draws/[drawId].
Data: Draw format list is static (8 rows, seeded) — can be fetched once and cached, or hardcoded from @courtsidedesk/scoring DRAW_FORMATS.
2.4 Draw Detail — /organizer/[tournamentId]/draws/[drawId]¶
Purpose: The main management screen for a single draw. This is the screen the organizer spends the most time on.
Layout — Two Tabs:
Tab A: Registrations
- Table of registered players: Name, Ranking, Seeded (checkbox), Q/WC/LL badge, Actions (remove).
- "Add Player" button opens a search dialog:
- Type-ahead search (player.search) across existing players.
- "Create New Player" inline option if no match found.
- Select one or more players -> calls registration.add or registration.bulkAdd.
- Inline seeding toggle: click to mark a player as seeded.
- "Edit Seeding Order" button -> navigates to /draws/[drawId]/seed.
Tab B: Bracket / Group
- If no matches generated: Shows empty state with a "Generate Bracket" (SE) or "Generate Schedule" (RR) button. This button navigates to the seeding screen first if any registrations are marked as seeded.
- If matches exist (SE): Renders the elimination bracket tree. Each match node shows:
- Player names (or "TBD" for unfilled slots).
- Score if completed.
- Status badge (Planned / On Court / Completed).
- Click on a match -> opens the result entry dialog (see 2.7).
- If matches exist (RR): Shows:
- Left: result grid (player-vs-player matrix).
- Right: standings table with POS, PLAYER, MP, MW, ML, SW, SL, GW, GL columns.
- Click on a grid cell -> opens the result entry dialog for that match.
- If standings have status: "manual-draw-required", show a warning banner indicating which players are tied and need a Losentscheid.
Data: draw.byId({ id: drawId }) for initial load. Supabase Realtime subscription on matches filtered by draw_id for live updates when another organizer tab enters results.
2.5 Seeding & Bracket Generation — /organizer/[tournamentId]/draws/[drawId]/seed¶
Purpose: Order players by seed before generating the bracket.
Content: - Draggable list of all registered players. Pre-sorted: seeded players first (by ranking), then unseeded alphabetically. - Each row shows: drag handle, seed number (auto-numbered from position), player name, ranking. - Only players explicitly dragged into the "seeded" zone get seed numbers. Remaining players are placed randomly. - "Generate Bracket" / "Generate Schedule" button at the bottom.
Submit: Calls draw.generateBracket({ drawId, seedOrder }). On success, navigates back to /draws/[drawId] Tab B.
Guard: If the draw already has matches, show a confirmation dialog: "This will delete all existing matches and results. Continue?" If confirmed, call draw.clearBracket first, then draw.generateBracket.
2.6 Court Assignment — /organizer/[tournamentId]/schedule¶
This IS SCR-005 from the requirements. The most interactive screen.
Layout:
- Left panel: Unassigned Matches — scrollable list. Each card shows: player names, draw name, round label, status.
- Right area: Court columns — one column per court, ordered by sort_order. Each column has:
- Header: court name.
- Current match (visually prominent, top).
- Upcoming matches sorted by planned_start_time ASC (nulls last).
- Completed matches collapsed/hidden by default (toggle to show).
Interactions:
- Drag match card from unassigned panel to a court column -> match.assignCourt({ matchId, courtId }).
- Drag between court columns -> match.assignCourt({ matchId, courtId: newCourtId }).
- Drag back to unassigned panel -> match.assignCourt({ matchId, courtId: null }).
- Click time on a match card -> inline datetime picker -> match.assignCourt({ matchId, courtId, plannedStartTime }).
- Click a match card -> opens result entry dialog (see 2.7).
- "Mark On Court" button on a PLANNED match -> match.setOnCourt({ matchId }).
Data: match.listByTournament({ tournamentId }) for initial load. Supabase Realtime subscription on matches filtered by tournament_id.
Optimistic updates: @dnd-kit drag operations update the UI immediately. The tRPC mutation runs in the background. On error, revert the UI and show a toast.
2.7 Result Entry Dialog¶
This is a modal/sheet, not a separate route. It opens from: - Draw Detail bracket/grid click. - Court Assignment match card click.
Content:
- Header: Player 1 vs Player 2, draw name, round label.
- Outcome selector: Radio group — Completed / Walkover / Default / Retirement.
- If Walkover or Default:
- Winner selector only (Player 1 / Player 2 radio).
- No set entry.
- If Completed or Retirement:
- Set entry rows. Each row has:
- Player 1 games input (number, 0-99).
- Player 2 games input (number, 0-99).
- Tiebreak score (optional, appears when games trigger a tiebreak per the draw format rules). Two number inputs for tiebreak points.
- Sets are added dynamically. Start with one set row. "Add Set" button (max = bestOf). For RETIREMENT, the last set's "incomplete" checkbox is auto-checked.
- For F3/F8 match tiebreak: a dedicated "Match Tiebreak" section appears when the decider set is reached. Two number inputs for MTB points.
- Winner is auto-derived from set scores for COMPLETED. For RETIREMENT, winner selector appears.
- Validation: Client-side preview using validateMatchResult() from @courtsidedesk/scoring imported in the browser. Real validation happens server-side in the mutation.
- Submit: Calls match.submitResult({ matchId, result }). The dialog closes on success and shows a toast. If the match is in an SE bracket, the toast confirms advancement ("Winner advances to SF").
2.8 Contact Sheet — /organizer/[tournamentId]/contacts¶
This IS SCR-003 from the requirements. Already specified in detail there.
Data: player.listForTournament({ tournamentId }).
3. Data Flow — Scoring Package Integration¶
3.1 Result Submission (the critical path)¶
[Frontend] [tRPC match.submitResult] [packages/scoring]
| | |
| 1. Build MatchResult object | |
| (client-side form state) | |
| | |
| 2. Client-side preview validation -----> | |
| validateMatchResult(format, result) | |
| (runs in browser, non-authoritative) | |
| | |
| 3. Submit via tRPC ===================> | |
| | |
| | 4. Zod parse (structural) --------> |
| | matchResultSchema.parse(input) |
| | |
| | 5. Load DrawFormat from DB |
| | |
| | 6. Semantic validation ------------> |
| | validateMatchResult(fmt, result) |
| | <-- ValidationResult ------------ |
| | |
| | 7. BEGIN TRANSACTION |
| | SELECT match FOR UPDATE |
| | UPDATE match SET result, winner |
| | |
| | 8. computeAdvancement() -----------> |
| | <-- AdvancementIntent ----------- |
| | |
| | 9. (SE) UPDATE next_match |
| | SET player1_id or player2_id |
| | |
| | 10. (RR) computeStandings() -------> |
| | <-- StandingsResult ------------ |
| | |
| | 11. COMMIT |
| | |
| <====== Response: match + advancement + standings |
| | |
| 12. Update local state | |
| 13. Supabase Realtime fires to | |
| all other subscribers | |
3.2 Key Integration Points¶
| Scoring function | Where called | Purpose |
|---|---|---|
matchResultSchema.parse() |
tRPC input validation (automatic via Zod) | Structural parse of the result payload |
validateMatchResult(format, result) |
match.submitResult / match.editResult server-side, AND client-side for preview |
Semantic validation against DrawFormat rules |
computeAdvancement(args) |
match.submitResult / match.editResult server-side only |
Determines which player advances and to which slot |
computeStandings(matches, players) |
draw.standings query, match.submitResult mutation (RR only), signage.draw query |
RR standings with tiebreak cascade |
parseMatchResult(jsonb) |
Every read path that touches matches.result |
Safe parse of JSONB column into typed MatchResult |
DRAW_FORMATS / getKnownFormat() |
Frontend draw creation form, client-side validation preview | Static format catalogue for dropdowns and client-side logic |
3.3 Realtime Event Flow¶
match.submitResult (tRPC mutation)
|
v
UPDATE matches row (Postgres)
|
v
Supabase Realtime publishes change event
|
+---> SCR-001 (Schedule Screen) — updates match status, score display
+---> SCR-002 (Draw/Bracket View) — updates bracket node, RR grid cell
+---> SCR-005 (Court Assignment) — updates match card in court column
+---> Other organizer tabs — sync state
The Supabase Realtime subscription filter is tournament_id = $currentTournamentId on the matches table. This is coarse enough to catch all relevant changes and fine enough to not receive noise from other tournaments.
For RR standings: the mutation response includes the recomputed StandingsResult. Other clients that receive the Realtime event for the match change must re-query draw.standings to get the updated standings. This is acceptable because standings computation is fast (sub-millisecond for groups of 8) and the query is a single procedure call. An alternative (broadcasting computed standings via Realtime) would require a Supabase Edge Function or custom channel, which adds complexity without meaningful benefit at this scale.
4. Open Questions¶
[DECIDED — PO Session 7] Bye handling in first-round brackets¶
Decision: Option A — auto-advance. Byes are resolved immediately during draw.generateBracket. First-round match created with one player, set to COMPLETED/WALKOVER, winner advanced to round 2.
[DECIDED — PO Session 7] Maximum group size for Round Robin¶
Decision: soft warn at 8, hard cap at 12. Accepted as recommended.
[DECIDED — PO Session 7, pending Rafael] Seeding placement algorithm¶
Decision: SwissTennis-specific (not generic ITF). Rafael to provide the exact SwissTennis seeding placement template. Implementation blocks on this for bracket generation.
[DECIDED — PO Session 7] Manual bracket slot placement¶
Decision: deferred to v2. The seeding algorithm + draw.generateBracket handles placement. No manual drag-to-slot in v1.
[DECIDED — PO Session 7] Result correction after advancement¶
Decision: block if downstream results exist, require reverse-order correction. Cascading deletion rejected. draw.clearBracket available as nuclear option.
5. Implementation Sequence¶
Recommended build order for Sam (backend) and Taylor (frontend):
Phase 1 — Player & Registration CRUD (1-2 days)¶
- Sam:
playerrouter (search, create, update),registrationrouter (add, remove, bulkAdd, updateSeeding). - Taylor: Add Player dialog with search, registration table on Draw Detail Tab A.
Phase 2 — Draw CRUD & Bracket Generation (2-3 days)¶
- Sam:
drawrouter (list, byId, create, update, delete, generateBracket, clearBracket). This is the hardest backend piece — the bracket generation algorithm with seeding placement and bye handling. - Taylor: Create Draw form, Draw List, Draw Detail Tab B (bracket view for SE, grid for RR). The bracket visualization is the hardest frontend piece.
Phase 3 — Match Result Entry (2-3 days)¶
- Sam:
match.submitResult,match.editResult,match.clearResult. Integrate@courtsidedesk/scoringvalidation and advancement. - Taylor: Result Entry Dialog with format-aware set inputs and client-side validation preview.
Phase 4 — Court Assignment / SCR-005 (2-3 days)¶
- Sam:
match.assignCourt,match.setOnCourt,match.reorderOnCourt,match.listByTournament. - Taylor: Court Assignment screen with
@dnd-kitdrag-and-drop.
Phase 5 — Signage Extension (1-2 days)¶
- Sam: Extend
signage.scheduleand addsignage.drawwith full match/result payloads. - Taylor: Migrate SCR-001 and SCR-002 off fixtures to live data.
Phase 6 — Realtime (1-2 days)¶
- Taylor: Supabase Realtime subscriptions on SCR-001, SCR-002, SCR-005.
- Sam: Verify Realtime publications are enabled on the
matchestable (already configured inpackages/db/src/policies/realtime.sql).
6. Zod Schema Reference¶
For Sam's convenience, the complete input schemas for the three most complex mutations:
draw.generateBracket input¶
import { z } from "zod";
export const generateBracketInput = z.object({
drawId: z.string().uuid(),
seedOrder: z.array(z.string().uuid()),
});
match.submitResult input¶
import { z } from "zod";
import { matchResultSchema } from "@courtsidedesk/scoring";
export const submitResultInput = z.object({
matchId: z.string().uuid(),
result: matchResultSchema,
});