@opndev/tennis-match-center 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /** How long a finished action keeps saying so, in milliseconds. */
6
+ const SETTLE_MS = 2000
7
+
8
+ const DEFAULT_LABELS = {
9
+ busy: 'Working…', done: 'Done', failed: 'Failed',
10
+ }
11
+
12
+ /**
13
+ * Runs something slow with the button reporting what it is doing.
14
+ *
15
+ * The button carries the state as `data-state` - busy, done or error -
16
+ * so a page can animate it, and says the same in its own text for a
17
+ * page that does not. It is disabled while the work is in flight, so a
18
+ * second press cannot start one on top of the first.
19
+ *
20
+ * @param {?HTMLElement} button
21
+ * @param {() => Promise<boolean>} run Resolves true where the work
22
+ * succeeded.
23
+ * @param {{busy: string, done: string, failed: string}} [labels] What
24
+ * the button says while it works, and afterwards.
25
+ * @returns {Promise<boolean>}
26
+ */
27
+ export async function withBusyButton(button, run, labels = {}) {
28
+ if (!button) {
29
+ return run()
30
+ }
31
+
32
+ const { busy, done, failed } = { ...DEFAULT_LABELS, ...labels }
33
+
34
+ const label = button.dataset.label ?? button.textContent
35
+ button.dataset.label = label
36
+ button.disabled = true
37
+ button.dataset.state = 'busy'
38
+ button.textContent = busy
39
+
40
+ let ok = false
41
+
42
+ try {
43
+ ok = await run()
44
+ } finally {
45
+ button.disabled = false
46
+ button.dataset.state = ok ? 'done' : 'error'
47
+ button.textContent = ok ? done : failed
48
+
49
+ const settle = setTimeout(() => {
50
+ button.dataset.state = 'idle'
51
+ button.textContent = label
52
+ }, SETTLE_MS)
53
+
54
+ settle.unref?.()
55
+ }
56
+
57
+ return ok
58
+ }
@@ -0,0 +1,586 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { HTMLElementSugar } from '@skirbi/sugar'
6
+ import { SingleEliminationBracket } from '../bracket-single-elimination.mjs'
7
+
8
+ const ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
9
+
10
+ /**
11
+ * <tennis-bracket-board api="..." save-api="...">
12
+ *
13
+ * Gate 2: Bracket Setup (spec.md). Loads the current seed-1 team of
14
+ * each saved Roster group from `api`, builds the first-round bracket
15
+ * client-side via SingleEliminationBracket, and only ever mutates the
16
+ * in-browser copy until Save is pressed.
17
+ *
18
+ * Renders the full bracket tree, not just the first round: later
19
+ * rounds are drawn as TBD placeholder slots so the shape of the draw
20
+ * is visible immediately, even though only first-round matches are
21
+ * actual saveable rows.
22
+ *
23
+ * v1: only the top seed of each group feeds the bracket (one entrant
24
+ * per group) - advancing more than one team per group, and filling
25
+ * later rounds once group play completes, are follow-up work.
26
+ */
27
+ export class TennisBracketBoardElement extends HTMLElementSugar {
28
+ static tag = 'tennis-bracket-board'
29
+ static renderGuardSelector = '[data-rounds]'
30
+
31
+ static attributeDefs = {
32
+ api: {},
33
+ 'save-api': {},
34
+ 'csrf-token': {},
35
+ }
36
+
37
+ static HtmlTemplate = ['tennis-bracket-board-template', function () {
38
+ return this.tpl(`
39
+ <div>
40
+ <p data-status>Loading…</p>
41
+ <div data-toolbar hidden>
42
+ <button data-prefill type="button">Prefill</button>
43
+ <button data-clear type="button">Clear</button>
44
+ <button data-reset type="button">Reset from database</button>
45
+ <button data-save type="button">Save</button>
46
+ </div>
47
+ <div data-rounds></div>
48
+ </div>
49
+ `)
50
+ }]
51
+
52
+ async connectedCallback() {
53
+ super.connectedCallback()
54
+
55
+ // Guard the one-time DOM setup, but always (re)fetch and render
56
+ // below. A morph-driven insertion can disconnect/reconnect this
57
+ // element once synchronously right after creation; if fetch were
58
+ // also skipped on the second connect, whichever instance ends up
59
+ // permanently live would never get real data.
60
+ if (!this.hasRenderedShape()) {
61
+ this.appendChild(this.renderFromTemplate())
62
+ }
63
+
64
+ if (!this._listenersBound) {
65
+ this._listenersBound = true
66
+ this.$('[data-prefill]').addEventListener('click', () => this.prefill())
67
+ this.$('[data-clear]').addEventListener('click', () => this.clear())
68
+ this.$('[data-reset]').addEventListener('click', () => this.resetFromDatabase())
69
+ this.$('[data-save]').addEventListener('click', () => this.save())
70
+ }
71
+
72
+ // That same double-connect means two of these fetches can be in
73
+ // flight at once. Without this token, whichever one resolves last
74
+ // wins - including a stray first-connect fetch resolving *after*
75
+ // the user has already clicked Prefill using the second connect's
76
+ // data, silently discarding it. Only the fetch started by the most
77
+ // recent connectedCallback call is allowed to apply its result.
78
+ const requestId = (this._fetchGeneration = (this._fetchGeneration ?? 0) + 1)
79
+
80
+ const { api } = this.getConfig()
81
+
82
+ const response = await fetch(api, {
83
+ headers: { Accept: 'application/json' },
84
+ })
85
+
86
+ const data = await response.json()
87
+
88
+ if (requestId !== this._fetchGeneration) {
89
+ return
90
+ }
91
+
92
+ this.saved = data
93
+ this.matches = this.saved.matches.map(match => ({ ...match }))
94
+
95
+ // Same reconstruction as resetFromDatabase() - only the first
96
+ // round becomes slots, not every saved round. Duplicated here
97
+ // rather than calling that method directly because this path
98
+ // additionally needs the stray-fetch guard above it.
99
+ this.round = firstRoundOf(this.matches)
100
+ this.firstRoundSlots = matchesToSlots(
101
+ this.matches.filter(match => match.round === this.round)
102
+ )
103
+
104
+ this.render()
105
+ }
106
+
107
+ prefill() {
108
+ const seeds = this.saved.seeds
109
+ const labelById = new Map(seeds.map(seed => [String(seed.team_id), seed.label]))
110
+
111
+ const pools = poolsFromSeeds(seeds)
112
+ // The draw is sized to the real number of advancing teams, not
113
+ // padded to some minimum - 2 advancing teams is a straight Final,
114
+ // 4 is a Final and a Semi-Final, nothing more. A forced-up draw
115
+ // gave every entrant a bye in rounds that shouldn't exist at all,
116
+ // and threw off which position in the draw each seed landed in.
117
+ const draw = Math.max(2, nextPowerOfTwo(seeds.length))
118
+
119
+ let round, rawMatches
120
+
121
+ if (draw < 8) {
122
+ // SingleEliminationBracket is a faithful port of a module built
123
+ // for real pro-tour draws (a mandatory minimum of 8, and
124
+ // seeded/unseeded/wildcard/qualifier pools meant to interleave a
125
+ // partly-seeded field of dozens) - a division's own top 2 or 4
126
+ // group finishers don't need any of that, they just need
127
+ // standard tournament seeding (1 vs 4, 2 vs 3 in a 4-draw, so
128
+ // the top two seeds can only meet in the Final).
129
+ const entrants = [...pools.seeded, ...pools.unseeded, ...pools.wildcards, ...pools.qualifiers]
130
+ const slots = standardSeedOrder(draw).map(seedNumber => entrants[seedNumber - 1] ?? 'Bye')
131
+
132
+ rawMatches = []
133
+ for (let i = 0; i < slots.length; i += 2) {
134
+ rawMatches.push(`${slots[i]} vs ${slots[i + 1]}`)
135
+ }
136
+ round = firstRoundForDraw(draw)
137
+ } else {
138
+ const bracket = new SingleEliminationBracket({
139
+ draw,
140
+ seeded: pools.seeded,
141
+ unseeded: pools.unseeded,
142
+ wildcards: pools.wildcards,
143
+ qualifiers: pools.qualifiers,
144
+ })
145
+
146
+ rawMatches = bracket.matches()
147
+ round = bracket.rounds()[0]
148
+ }
149
+
150
+ this.round = round
151
+
152
+ this.firstRoundSlots = rawMatches.map(entry => {
153
+ const [home, visitor] = entry.split(' vs ')
154
+
155
+ return {
156
+ home_team_id: home === 'Bye' ? null : Number(home),
157
+ home_label: home === 'Bye' ? 'Bye' : labelById.get(home),
158
+ visitor_team_id: visitor === 'Bye' ? null : Number(visitor),
159
+ visitor_label: visitor === 'Bye' ? 'Bye' : labelById.get(visitor),
160
+ }
161
+ })
162
+
163
+ this.rebuildMatches()
164
+ this.render()
165
+ }
166
+
167
+ clear() {
168
+ this.matches = []
169
+ this.firstRoundSlots = []
170
+ this.render()
171
+ }
172
+
173
+ resetFromDatabase() {
174
+ this.matches = this.saved.matches.map(match => ({ ...match }))
175
+
176
+ // Every round is saved, first round included - matchesToSlots must
177
+ // only ever see that one. Handed the whole list, firstRoundSlots
178
+ // silently became "every saved match, whichever round", so its
179
+ // length (used everywhere downstream - render()'s column counts,
180
+ // rebuildMatches()' later-round shell counts) no longer meant "one
181
+ // per first-round match": a 4-real-match, 3-TBD-round save came
182
+ // back looking like an 11-wide first round.
183
+ this.round = firstRoundOf(this.matches)
184
+ this.firstRoundSlots = matchesToSlots(
185
+ this.matches.filter(match => match.round === this.round)
186
+ )
187
+ this.render()
188
+ }
189
+
190
+ /**
191
+ * Manual override (spec.md's "Positions can be swapped manually to
192
+ * override the seeding"): exchanges two first-round slots wholesale.
193
+ * Only the first round is editable - later rounds are TBD
194
+ * placeholders with nothing real to swap yet.
195
+ */
196
+ swapEntrants(indexA, sideA, indexB, sideB) {
197
+ if (indexA === indexB && sideA === sideB) {
198
+ return
199
+ }
200
+
201
+ const a = this.firstRoundSlots[indexA]
202
+ const b = this.firstRoundSlots[indexB]
203
+
204
+ const aTeamId = a[`${sideA}_team_id`]
205
+ const aLabel = a[`${sideA}_label`]
206
+
207
+ a[`${sideA}_team_id`] = b[`${sideB}_team_id`]
208
+ a[`${sideA}_label`] = b[`${sideB}_label`]
209
+ b[`${sideB}_team_id`] = aTeamId
210
+ b[`${sideB}_label`] = aLabel
211
+
212
+ this.rebuildMatches()
213
+
214
+ this.dispatchEvent(new CustomEvent('tennis-bracket:changed', {
215
+ bubbles: true,
216
+ detail: { firstRoundSlots: this.firstRoundSlots },
217
+ }))
218
+
219
+ this.render()
220
+ }
221
+
222
+ /**
223
+ * Every later round is persisted as TBD shells too, not just the
224
+ * first. Planning and Scheduling both need to see the whole bracket
225
+ * in advance to reserve capacity for it - the entrants are unknown,
226
+ * the match count is not.
227
+ */
228
+ rebuildMatches() {
229
+ // A bye is still a real slot - the team standing in it is walking
230
+ // straight through to the next round, not absent from the draw.
231
+ // Requiring both sides filled dropped every "X vs Bye" match
232
+ // before it ever reached the server: only the other, genuinely
233
+ // paired matches got saved, so a reload could no longer say which
234
+ // team the bye even belonged to - just that some entrant, unnamed,
235
+ // must have advanced.
236
+ const firstRound = this.firstRoundSlots
237
+ .filter(slot => slot.home_team_id || slot.visitor_team_id)
238
+ .map(slot => ({
239
+ round: this.round,
240
+ home_team_id: slot.home_team_id,
241
+ home_label: slot.home_label,
242
+ visitor_team_id: slot.visitor_team_id,
243
+ visitor_label: slot.visitor_label,
244
+ }))
245
+
246
+ const laterRounds = []
247
+ let count = Math.max(1, Math.floor(this.firstRoundSlots.length / 2))
248
+
249
+ for (const round of roundsFrom(this.round).slice(1)) {
250
+ for (let i = 0; i < count; i++) {
251
+ laterRounds.push({
252
+ round,
253
+ home_team_id: null,
254
+ home_label: 'TBD',
255
+ visitor_team_id: null,
256
+ visitor_label: 'TBD',
257
+ })
258
+ }
259
+
260
+ if (round === 'F') {
261
+ break
262
+ }
263
+
264
+ count = Math.max(1, Math.floor(count / 2))
265
+ }
266
+
267
+ this.matches = [...firstRound, ...laterRounds]
268
+ }
269
+
270
+ render() {
271
+ this.$('[data-status]').hidden = true
272
+ this.$('[data-toolbar]').hidden = false
273
+
274
+ const container = this.$('[data-rounds]')
275
+ container.replaceChildren()
276
+
277
+ const slotCount = this.firstRoundSlots.length
278
+
279
+ if (!slotCount) {
280
+ return
281
+ }
282
+
283
+ const firstRound = this.matches[0]?.round ?? this.firstRoundSlots[0]?.round
284
+ const roundNames = roundsFrom(firstRound ?? ROUND_ORDER.at(-1))
285
+
286
+ let count = slotCount
287
+
288
+ for (const round of roundNames) {
289
+ container.appendChild(this.columnFor(round, count, round === roundNames[0]))
290
+
291
+ if (round === 'F') break
292
+
293
+ count = Math.max(1, Math.floor(count / 2))
294
+ }
295
+ }
296
+
297
+ columnFor(round, slotCount, isFirstRound) {
298
+ const column = document.createElement('div')
299
+
300
+ column.dataset.round = round
301
+
302
+ const heading = document.createElement('h4')
303
+ heading.textContent = round
304
+ column.appendChild(heading)
305
+
306
+ const list = document.createElement('div')
307
+ list.dataset.roundMatches = ''
308
+
309
+ for (let i = 0; i < slotCount; i++) {
310
+ const slot = isFirstRound ? this.firstRoundSlots[i] : null
311
+
312
+ list.appendChild(this.matchBoxFor(slot, isFirstRound ? i : null))
313
+ }
314
+
315
+ column.appendChild(list)
316
+
317
+ return column
318
+ }
319
+
320
+ matchBoxFor(slot, slotIndex) {
321
+ const box = document.createElement('div')
322
+ box.dataset.matchBox = ''
323
+
324
+ box.appendChild(this.entrantRowFor(slot, 'home', slotIndex))
325
+ box.appendChild(this.entrantRowFor(slot, 'visitor', slotIndex))
326
+
327
+ return box
328
+ }
329
+
330
+ /**
331
+ * One side of a match box. This shows the draw's structure - "Winner
332
+ * A", "Runner-up B" - not the real team name: at Gate 2 nothing is
333
+ * actually decided yet (groups haven't been played), so a real name
334
+ * here would overstate certainty the bracket doesn't have. Once group
335
+ * results exist and shells get filled from actual standings, this is
336
+ * where that real name will replace the slot label.
337
+ */
338
+ entrantRowFor(slot, side, slotIndex) {
339
+ const row = document.createElement('div')
340
+ row.dataset[side] = ''
341
+
342
+ const label = document.createElement('span')
343
+ label.dataset.entrantLabel = ''
344
+
345
+ const teamId = slot?.[`${side}_team_id`]
346
+ const origin = teamId != null ? this.seedOrigins().get(teamId) : null
347
+
348
+ // Never fall back to the real team label here, even if the origin
349
+ // lookup fails (e.g. a saved match whose team no longer appears in
350
+ // the current seed list after groups were regenerated) - this view
351
+ // is the draw's configuration, not a roster, on purpose. A missing
352
+ // `slot` is a not-yet-generated later round (TBD); a slot whose
353
+ // side has no team_id is a real bye.
354
+ label.textContent = !slot
355
+ ? 'TBD'
356
+ : teamId == null
357
+ ? 'Bye'
358
+ : origin
359
+ ? `${tierLabel(origin.tier)} ${origin.groupName}`
360
+ : 'Unknown slot'
361
+
362
+ row.appendChild(label)
363
+
364
+ // Only first-round slots are real - later rounds are TBD
365
+ // placeholders with nothing to swap yet.
366
+ if (slotIndex != null) {
367
+ this.makeSwappable(row, slotIndex, side)
368
+ }
369
+
370
+ return row
371
+ }
372
+
373
+ makeSwappable(row, slotIndex, side) {
374
+ row.draggable = true
375
+
376
+ row.addEventListener('dragstart', event => {
377
+ event.dataTransfer.setData('text/plain', JSON.stringify({ slotIndex, side }))
378
+ event.dataTransfer.effectAllowed = 'move'
379
+ row.dataset.dragging = ''
380
+ })
381
+
382
+ row.addEventListener('dragend', () => {
383
+ delete row.dataset.dragging
384
+ })
385
+
386
+ row.addEventListener('dragover', event => {
387
+ event.preventDefault()
388
+ event.dataTransfer.dropEffect = 'move'
389
+ row.dataset.dragover = ''
390
+ })
391
+
392
+ row.addEventListener('dragleave', () => {
393
+ delete row.dataset.dragover
394
+ })
395
+
396
+ row.addEventListener('drop', event => {
397
+ event.preventDefault()
398
+ delete row.dataset.dragover
399
+
400
+ const from = JSON.parse(event.dataTransfer.getData('text/plain'))
401
+
402
+ this.swapEntrants(from.slotIndex, from.side, slotIndex, side)
403
+ })
404
+ }
405
+
406
+ /**
407
+ * team_id -> { groupName, tier }, from the currently loaded seed
408
+ * list. Kept live off `this.saved.seeds` rather than baked into
409
+ * matches, so origin tags still show up for matches loaded back from
410
+ * the database (TournamentMatch itself doesn't store group/tier).
411
+ */
412
+ seedOrigins() {
413
+ return new Map(
414
+ this.saved.seeds.map(seed => [
415
+ seed.team_id,
416
+ { groupName: seed.group_name, tier: seed.tier },
417
+ ])
418
+ )
419
+ }
420
+
421
+ async save() {
422
+ const config = this.getConfig()
423
+
424
+ const matches = this.matches.map(match => ({
425
+ round: match.round,
426
+ home_team_id: match.home_team_id,
427
+ visitor_team_id: match.visitor_team_id,
428
+ }))
429
+
430
+ const response = await fetch(config['save-api'], {
431
+ method: 'POST',
432
+ headers: {
433
+ Accept: 'application/json',
434
+ 'Content-Type': 'application/json',
435
+ 'X-CSRF-TOKEN': config['csrf-token'],
436
+ },
437
+ body: JSON.stringify({ matches }),
438
+ })
439
+
440
+ const body = await response.json()
441
+
442
+ if (!response.ok) {
443
+ this.dispatchEvent(new CustomEvent('tennis-bracket:save-error', {
444
+ bubbles: true,
445
+ detail: body,
446
+ }))
447
+ return
448
+ }
449
+
450
+ this.saved = body
451
+ this.matches = this.saved.matches.map(match => ({ ...match }))
452
+
453
+ // Same reconstruction as resetFromDatabase()/connectedCallback() -
454
+ // only the first round becomes slots. A third place this bug
455
+ // lived: the response to Save itself updates the view in place,
456
+ // before any reload - so this is the one a director actually saw
457
+ // first, immediately after clicking Save.
458
+ this.round = firstRoundOf(this.matches)
459
+ this.firstRoundSlots = matchesToSlots(
460
+ this.matches.filter(match => match.round === this.round)
461
+ )
462
+
463
+ this.dispatchEvent(new CustomEvent('tennis-bracket:save', {
464
+ bubbles: true,
465
+ detail: { state: this.saved },
466
+ }))
467
+
468
+ this.render()
469
+ }
470
+ }
471
+
472
+ function tierLabel(tier) {
473
+ return { 1: 'Winner', 2: 'Runner-up' }[tier] ?? `${tier}th place`
474
+ }
475
+
476
+ /**
477
+ * Buckets seeds by advancement tier (1st of each group, 2nd of each
478
+ * group, ...) into SingleEliminationBracket's four pools, so a group's
479
+ * own representatives are kept apart rather than merely ranked by
480
+ * overall strength - the generic seeding algorithm has no notion of
481
+ * "these two came from the same source" on its own.
482
+ *
483
+ * Tier 1 -> seeded, in group order. Every later tier is rotated by
484
+ * half its own length before becoming the next pool, so
485
+ * pool[i] never comes from the same group as seeded[i]: with the
486
+ * algorithm's own "Seed k vs Unseeded k" round-1 pairing, that keeps a
487
+ * group's pair in different bracket positions from round 1 onward.
488
+ * When the tier count is a power of two this provably keeps a group's
489
+ * representatives in opposite halves of the draw (they can only meet
490
+ * in the Final) - see bracket-board.test for the worked proof. With an
491
+ * uneven group count or more than 4 tiers (this pool mechanism only
492
+ * has four) the guarantee is best-effort rather than proven.
493
+ */
494
+ function poolsFromSeeds(seeds) {
495
+ const tierNumbers = [...new Set(seeds.map(seed => seed.tier))].sort((a, b) => a - b)
496
+ const poolNames = ['seeded', 'unseeded', 'wildcards', 'qualifiers']
497
+
498
+ const pools = { seeded: [], unseeded: [], wildcards: [], qualifiers: [] }
499
+
500
+ tierNumbers.forEach((tier, tierIndex) => {
501
+ const ordered = seeds
502
+ .filter(seed => seed.tier === tier)
503
+ .sort((a, b) => a.group_name.localeCompare(b.group_name))
504
+ .map(seed => String(seed.team_id))
505
+
506
+ const rotated = tierIndex === 0
507
+ ? ordered
508
+ : rotate(ordered, Math.floor(ordered.length / 2))
509
+
510
+ // A 5th+ tier has nowhere to go - the algorithm only has four
511
+ // pools - so it folds into qualifiers rather than being dropped.
512
+ const poolName = poolNames[tierIndex] ?? 'qualifiers'
513
+
514
+ pools[poolName].push(...rotated)
515
+ })
516
+
517
+ return pools
518
+ }
519
+
520
+ function rotate(array, offset) {
521
+ return [...array.slice(offset), ...array.slice(0, offset)]
522
+ }
523
+
524
+ function nextPowerOfTwo(n) {
525
+ let value = 1
526
+
527
+ while (value < n) {
528
+ value *= 2
529
+ }
530
+
531
+ return value
532
+ }
533
+
534
+ /**
535
+ * Standard tournament seeding order for a draw of n (a power of 2):
536
+ * which seed number occupies each bracket position, so that pairing
537
+ * consecutive positions gives 1 vs n, 2 vs (n-1), etc. one level down,
538
+ * recursively - the same convention SingleEliminationBracket documents
539
+ * for its own (8+) draws, worked out directly here since that module
540
+ * won't build draws this small.
541
+ */
542
+ function standardSeedOrder(n) {
543
+ if (n === 1) {
544
+ return [1]
545
+ }
546
+
547
+ return standardSeedOrder(n / 2).flatMap(seed => [seed, n + 1 - seed])
548
+ }
549
+
550
+ /**
551
+ * The round a draw of this size starts in - a 4-draw starts at SF, a
552
+ * 2-draw is just the Final. Matches ROUND_ORDER's own convention
553
+ * (bracket.rounds()[0] for an 8+ draw): the Final is always last.
554
+ */
555
+ function firstRoundForDraw(draw) {
556
+ return ROUND_ORDER[ROUND_ORDER.length - Math.log2(draw)]
557
+ }
558
+
559
+ function roundsFrom(firstRound) {
560
+ const idx = ROUND_ORDER.indexOf(firstRound)
561
+
562
+ return idx === -1 ? [firstRound] : ROUND_ORDER.slice(idx)
563
+ }
564
+
565
+ /**
566
+ * The widest round among a set of saved matches - not just whichever
567
+ * happens to be first in the array. The store endpoint saves every
568
+ * round in one transaction with no ORDER BY on the way back out, so
569
+ * matches[0] is whatever the database felt like returning first, not
570
+ * necessarily the actual first round.
571
+ */
572
+ function firstRoundOf(matches) {
573
+ const present = new Set(matches.map(match => match.round))
574
+
575
+ return ROUND_ORDER.find(round => present.has(round)) ?? matches[0]?.round
576
+ }
577
+
578
+ function matchesToSlots(matches) {
579
+ return matches.map(match => ({
580
+ home_team_id: match.home_team_id,
581
+ home_label: match.home_label,
582
+ visitor_team_id: match.visitor_team_id,
583
+ visitor_label: match.visitor_label,
584
+ }))
585
+ }
586
+