@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.
- package/Changes +5 -0
- package/LICENSE +235 -0
- package/README.md +218 -0
- package/lib/auto-planner.mjs +1297 -0
- package/lib/bracket-autofill.mjs +174 -0
- package/lib/bracket-single-elimination.mjs +496 -0
- package/lib/busy-button.mjs +58 -0
- package/lib/components/bracket-board.mjs +586 -0
- package/lib/components/planning-board.mjs +1179 -0
- package/lib/components/roster-board.mjs +273 -0
- package/lib/components/roster-team.mjs +92 -0
- package/lib/components/schedule-board.mjs +1193 -0
- package/lib/court-bands.mjs +185 -0
- package/lib/court-runs.mjs +76 -0
- package/lib/day-rows.mjs +116 -0
- package/lib/division-hues.mjs +52 -0
- package/lib/five-team-planner.mjs +21 -0
- package/lib/flags.mjs +60 -0
- package/lib/four-team-planner.mjs +216 -0
- package/lib/group-schedule.mjs +175 -0
- package/lib/index.mjs +9 -0
- package/lib/knockout-draw.mjs +209 -0
- package/lib/knockout-planner.mjs +573 -0
- package/lib/knockout-waves.mjs +156 -0
- package/lib/lighting.mjs +50 -0
- package/lib/match-time.mjs +13 -0
- package/lib/occupancy.mjs +74 -0
- package/lib/paired-court-planner.mjs +236 -0
- package/lib/plan-placement.mjs +219 -0
- package/lib/plan.mjs +133 -0
- package/lib/planning-day.mjs +177 -0
- package/lib/roster-seeding.mjs +65 -0
- package/lib/run-queue-planner.mjs +603 -0
- package/lib/sessions.mjs +48 -0
- package/lib/three-team-planner.mjs +122 -0
- package/lib/webcomponents.mjs +25 -0
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
- package/package.json +39 -0
|
@@ -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
|
+
|