@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,1193 @@
|
|
|
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 {
|
|
7
|
+
groupFixtures, isBye, placeGroupsIntoCells, placeKnockoutIntoCells,
|
|
8
|
+
} from '../plan-placement.mjs'
|
|
9
|
+
import { competitionHue, divisionHues } from '../division-hues.mjs'
|
|
10
|
+
import { setNameText } from '../flags.mjs'
|
|
11
|
+
import { rowsForDate } from '../day-rows.mjs'
|
|
12
|
+
import { withBusyButton } from '../busy-button.mjs'
|
|
13
|
+
|
|
14
|
+
const KNOCKOUT_ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
|
|
15
|
+
|
|
16
|
+
const SAVING = { busy: 'Saving…', done: 'Saved', failed: 'Save failed' }
|
|
17
|
+
const CLEARING = { busy: 'Clearing…', done: 'Cleared', failed: 'Clear failed' }
|
|
18
|
+
const GENERATING = {
|
|
19
|
+
busy: 'Generating…', done: 'Generated', failed: 'Nothing to generate',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* <tennis-schedule-board shell-api="..." day-api="..."
|
|
24
|
+
* competition-api="..." save-api="..." clear-api="...">
|
|
25
|
+
*
|
|
26
|
+
* The actual Gate 3 "Court Scheduler" from spec.md: every division's
|
|
27
|
+
* group matches, generated from spec.md's fixed templates
|
|
28
|
+
* (group-schedule.mjs), assigned to real court+hour cells. Unlike
|
|
29
|
+
* Planning (which only allocates capacity - which competition gets
|
|
30
|
+
* which cells), this board fills those cells with real matches.
|
|
31
|
+
*
|
|
32
|
+
* One day-tabbed grid, like Planning, but read-only cells (no
|
|
33
|
+
* drag/select) - the "Generate schedule" button per competition is
|
|
34
|
+
* the only way to fill or refill its matches, since the whole point
|
|
35
|
+
* is automating this rather than placing matches by hand. Generating
|
|
36
|
+
* again for a competition replaces only that competition's matches;
|
|
37
|
+
* every other competition's matches are untouched.
|
|
38
|
+
*
|
|
39
|
+
* Day tabs are built as a <semtic-tabbed> subtree entirely in memory
|
|
40
|
+
* and inserted in one shot - see planning-board.mjs's doc comment for
|
|
41
|
+
* why (same underlying component, same constraint).
|
|
42
|
+
*
|
|
43
|
+
* The board is fetched in three pieces, as Planning is: the shell
|
|
44
|
+
* (courts, days, each competition's running order) draws the grid,
|
|
45
|
+
* day-api fills the day a director is actually looking at, and
|
|
46
|
+
* competition-api is fetched only when that competition generates.
|
|
47
|
+
* A whole schedule is three quarters of a megabyte, which a slow line
|
|
48
|
+
* feels whatever the server does.
|
|
49
|
+
*
|
|
50
|
+
* "Clear schedule (all)"/"(day)" are deletes, not saves: which rows to
|
|
51
|
+
* drop follows from the event and the date, so a board holding one day
|
|
52
|
+
* cannot wipe the days it never held.
|
|
53
|
+
*/
|
|
54
|
+
export class TennisScheduleBoardElement extends HTMLElementSugar {
|
|
55
|
+
static tag = 'tennis-schedule-board'
|
|
56
|
+
static renderGuardSelector = '[data-schedule]'
|
|
57
|
+
|
|
58
|
+
static attributeDefs = {
|
|
59
|
+
'shell-api': {},
|
|
60
|
+
'day-api': {},
|
|
61
|
+
'competition-api': {},
|
|
62
|
+
'save-api': {},
|
|
63
|
+
'clear-api': {},
|
|
64
|
+
'csrf-token': {},
|
|
65
|
+
'flag-svg': { default: 'windows' },
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static HtmlTemplate = ['tennis-schedule-board-template', function () {
|
|
69
|
+
return this.tpl(`
|
|
70
|
+
<div>
|
|
71
|
+
<p data-status>Loading…</p>
|
|
72
|
+
<button data-clear-all type="button" hidden>Clear schedule (all)</button>
|
|
73
|
+
<button data-clear-day type="button" hidden>Clear schedule (day)</button>
|
|
74
|
+
<ul data-legend hidden></ul>
|
|
75
|
+
<label data-view-toggle hidden>
|
|
76
|
+
<input data-show-data type="checkbox">
|
|
77
|
+
Show data
|
|
78
|
+
</label>
|
|
79
|
+
<pre data-schedule-data hidden></pre>
|
|
80
|
+
<div data-schedule hidden></div>
|
|
81
|
+
<button data-save type="button" hidden>Save schedule</button>
|
|
82
|
+
</div>
|
|
83
|
+
`)
|
|
84
|
+
}]
|
|
85
|
+
|
|
86
|
+
async connectedCallback() {
|
|
87
|
+
super.connectedCallback()
|
|
88
|
+
|
|
89
|
+
// See planning-board.mjs's connectedCallback for why the DOM-setup
|
|
90
|
+
// guard and the fetch+render are split, and why a generation token
|
|
91
|
+
// guards the fetch - same double-connect/race concerns apply here.
|
|
92
|
+
if (!this.hasRenderedShape()) {
|
|
93
|
+
this.appendChild(this.renderFromTemplate())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!this._listenersBound) {
|
|
97
|
+
this._listenersBound = true
|
|
98
|
+
this.$('[data-save]').addEventListener('click', () => this.save())
|
|
99
|
+
this.$('[data-clear-all]').addEventListener('click', event => {
|
|
100
|
+
withBusyButton(event.currentTarget, () => this.clearAll(), CLEARING)
|
|
101
|
+
})
|
|
102
|
+
this.$('[data-clear-day]').addEventListener('click', event => {
|
|
103
|
+
const activeDate = this.$('[data-schedule] semtic-tabbed')?.getAttribute('current')
|
|
104
|
+
withBusyButton(
|
|
105
|
+
event.currentTarget, () => this.clearDay(activeDate), CLEARING
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
this.$('[data-show-data]').addEventListener('change', () => this.render())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const shellApplied = await this.fetchShell()
|
|
112
|
+
|
|
113
|
+
if (!shellApplied) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.render()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The grid and nothing in it: courts, days, and each competition's
|
|
122
|
+
* running order. Everything a cell holds arrives per day, and what a
|
|
123
|
+
* competition needs to work out its matches arrives when it
|
|
124
|
+
* generates - a whole schedule is three quarters of a megabyte, and
|
|
125
|
+
* a director waiting on all of it to see any of it is the slow part
|
|
126
|
+
* on a slow line.
|
|
127
|
+
*
|
|
128
|
+
* @returns {Promise<boolean>}
|
|
129
|
+
*/
|
|
130
|
+
async fetchShell() {
|
|
131
|
+
const data = await this.fetchJSON(this.getConfig()['shell-api'])
|
|
132
|
+
|
|
133
|
+
if (!data) {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.state = {
|
|
138
|
+
...data,
|
|
139
|
+
competitions: data.competitions.map(competition => ({
|
|
140
|
+
cells: [],
|
|
141
|
+
knockout_cells: [],
|
|
142
|
+
groups: [],
|
|
143
|
+
brackets: [],
|
|
144
|
+
teams: {},
|
|
145
|
+
loaded: false,
|
|
146
|
+
...competition,
|
|
147
|
+
})),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.matches = []
|
|
151
|
+
this.loadedDates = new Set()
|
|
152
|
+
this._divisionHues = null
|
|
153
|
+
this.teamNames = {}
|
|
154
|
+
this.groupsById = {}
|
|
155
|
+
this.divisionNamesById = {}
|
|
156
|
+
this.unscheduledByCompetition = new Map()
|
|
157
|
+
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One day's cells and matches, folded into the competitions the
|
|
163
|
+
* shell already drew. A competition that has since been fetched
|
|
164
|
+
* whole is skipped: it holds every day of its own already, and
|
|
165
|
+
* adding this day's cells to it again would double them.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} date
|
|
168
|
+
* @returns {Promise<boolean>}
|
|
169
|
+
*/
|
|
170
|
+
async fetchDay(date) {
|
|
171
|
+
if (this.loadedDates.has(date)) {
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const data = await this.fetchJSON(`${this.getConfig()['day-api']}/${date}`)
|
|
176
|
+
|
|
177
|
+
if (!data) {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const competition of this.state.competitions) {
|
|
182
|
+
if (competition.loaded) {
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const cells = data.cells.filter(
|
|
187
|
+
cell => cell.competition_id === competition.id
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
competition.cells = competition.cells
|
|
191
|
+
.filter(cell => cell.date !== date)
|
|
192
|
+
.concat(cells.filter(cell => cell.phase !== 'knockout'))
|
|
193
|
+
competition.knockout_cells = competition.knockout_cells
|
|
194
|
+
.filter(cell => cell.date !== date)
|
|
195
|
+
.concat(cells.filter(cell => cell.phase === 'knockout'))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const generated = this.generatedDivisionIds()
|
|
199
|
+
|
|
200
|
+
this.matches = this.matches
|
|
201
|
+
.filter(match => match.date !== date || generated.has(match.division_id))
|
|
202
|
+
.concat(data.matches.filter(match => !generated.has(match.division_id)))
|
|
203
|
+
|
|
204
|
+
this.learnNames(data)
|
|
205
|
+
|
|
206
|
+
this.loadedDates.add(date)
|
|
207
|
+
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Remembers who and what a payload's matches are.
|
|
213
|
+
*
|
|
214
|
+
* Merged rather than replaced: a day knows the names of what is on
|
|
215
|
+
* it and a competition knows its own, and a board that draws one day
|
|
216
|
+
* while holding another needs both.
|
|
217
|
+
*
|
|
218
|
+
* @param {{teams: ?Object, groups: ?Object, divisions: ?Object}} data
|
|
219
|
+
*/
|
|
220
|
+
learnNames(data) {
|
|
221
|
+
Object.assign(this.teamNames, data.teams ?? {})
|
|
222
|
+
Object.assign(this.groupsById, data.groups ?? {})
|
|
223
|
+
Object.assign(this.divisionNamesById, data.divisions ?? {})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The divisions whose matches the board itself is now the source of.
|
|
228
|
+
*
|
|
229
|
+
* A competition that has been generated holds its whole week here,
|
|
230
|
+
* saved or not. Opening a day after that must not put the stored
|
|
231
|
+
* version of those matches back over the generated one.
|
|
232
|
+
*
|
|
233
|
+
* @returns {Set<number>}
|
|
234
|
+
*/
|
|
235
|
+
generatedDivisionIds() {
|
|
236
|
+
const divisionIds = new Set()
|
|
237
|
+
|
|
238
|
+
for (const competition of this.state.competitions) {
|
|
239
|
+
if (!competition.loaded) {
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const divisionId of this.divisionIdsFor(competition)) {
|
|
244
|
+
divisionIds.add(divisionId)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return divisionIds
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Everything one competition needs to generate: its groups, its
|
|
253
|
+
* brackets, its names, and its cells on every day - not only the
|
|
254
|
+
* days a director happens to have opened.
|
|
255
|
+
*
|
|
256
|
+
* @param {number} competitionId
|
|
257
|
+
* @returns {Promise<boolean>}
|
|
258
|
+
*/
|
|
259
|
+
async fetchCompetition(competitionId) {
|
|
260
|
+
const index = this.state.competitions.findIndex(
|
|
261
|
+
competition => competition.id === competitionId
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if (index < 0) {
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (this.state.competitions[index].loaded) {
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const data = await this.fetchJSON(
|
|
273
|
+
`${this.getConfig()['competition-api']}/${competitionId}`
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if (!data) {
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.state.competitions[index] = {
|
|
281
|
+
...this.state.competitions[index], ...data, loaded: true,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.learnNames({
|
|
285
|
+
teams: this.state.competitions[index].teams,
|
|
286
|
+
groups: this.groupNamesOf(this.state.competitions[index]),
|
|
287
|
+
divisions: this.divisionNamesOf(this.state.competitions[index]),
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Stale-request guard, per URL: a day fetched while a competition is
|
|
295
|
+
* still coming must not discard the competition, and the other way
|
|
296
|
+
* round, so each endpoint counts its own requests.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} url
|
|
299
|
+
* @returns {Promise<?Object>} null if a newer request for the same
|
|
300
|
+
* URL has since started.
|
|
301
|
+
*/
|
|
302
|
+
async fetchJSON(url) {
|
|
303
|
+
this._fetchGenerations ??= new Map()
|
|
304
|
+
|
|
305
|
+
const requestId = (this._fetchGenerations.get(url) ?? 0) + 1
|
|
306
|
+
this._fetchGenerations.set(url, requestId)
|
|
307
|
+
|
|
308
|
+
const response = await fetch(url, {
|
|
309
|
+
headers: { Accept: 'application/json' },
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
const data = await response.json()
|
|
313
|
+
|
|
314
|
+
if (requestId !== this._fetchGenerations.get(url)) {
|
|
315
|
+
return null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return data
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* group_id -> {division_name, name}, so a cell can show which
|
|
323
|
+
* division/group a match belongs to without knowing which
|
|
324
|
+
* competition it came from.
|
|
325
|
+
*/
|
|
326
|
+
groupNamesOf(competition) {
|
|
327
|
+
const groups = {}
|
|
328
|
+
|
|
329
|
+
for (const group of competition.groups) {
|
|
330
|
+
groups[group.id] = {
|
|
331
|
+
division_name: group.division_name, name: group.name,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return groups
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
divisionNamesOf(competition) {
|
|
339
|
+
const names = {}
|
|
340
|
+
|
|
341
|
+
for (const bracket of competition.brackets ?? []) {
|
|
342
|
+
names[bracket.division_id] = bracket.division_name
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return names
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
divisionIdsFor(competition) {
|
|
349
|
+
const divisionIds = new Set(competition.groups.map(group => group.division_id))
|
|
350
|
+
for (const bracket of competition.brackets ?? []) {
|
|
351
|
+
divisionIds.add(bracket.division_id)
|
|
352
|
+
}
|
|
353
|
+
return divisionIds
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Reshapes the saved bracket shells into the round-grouped form the
|
|
358
|
+
* knockout scheduler wants. Only rounds that actually have shells
|
|
359
|
+
* appear, so a half-built bracket doesn't produce empty rounds.
|
|
360
|
+
*/
|
|
361
|
+
bracketsFor(competition) {
|
|
362
|
+
return (competition.brackets ?? []).map(bracket => ({
|
|
363
|
+
name: bracket.division_name,
|
|
364
|
+
rounds: KNOCKOUT_ROUND_ORDER
|
|
365
|
+
.filter(round => bracket.matches.some(match => match.round === round))
|
|
366
|
+
.map(round => ({
|
|
367
|
+
name: round,
|
|
368
|
+
matches: bracket.matches
|
|
369
|
+
.filter(match => match.round === round)
|
|
370
|
+
.map(match => ({
|
|
371
|
+
id: match.id,
|
|
372
|
+
division_id: bracket.division_id,
|
|
373
|
+
home_team_id: match.home_team_id,
|
|
374
|
+
visitor_team_id: match.visitor_team_id,
|
|
375
|
+
})),
|
|
376
|
+
})),
|
|
377
|
+
}))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Runs the generator for one competition's groups against its own
|
|
382
|
+
* cells, replacing that competition's matches - every other
|
|
383
|
+
* competition's matches are left exactly as they were.
|
|
384
|
+
*
|
|
385
|
+
* Fetches that competition first: a board holds a day at a time, and
|
|
386
|
+
* generating against the days a director happened to open would
|
|
387
|
+
* leave the rest of the week unscheduled.
|
|
388
|
+
*/
|
|
389
|
+
async generate(competitionId) {
|
|
390
|
+
if (!await this.fetchCompetition(competitionId)) {
|
|
391
|
+
return false
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const competition = this.state.competitions.find(c => c.id === competitionId)
|
|
395
|
+
|
|
396
|
+
if (!competition) {
|
|
397
|
+
return false
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const { matches, unplaced } = placeGroupsIntoCells({
|
|
401
|
+
cells: competition.cells,
|
|
402
|
+
groups: competition.groups.map(group => ({
|
|
403
|
+
id: group.id,
|
|
404
|
+
divisionId: group.division_id,
|
|
405
|
+
teamIds: group.team_ids,
|
|
406
|
+
groupSize: group.pool_size,
|
|
407
|
+
})),
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const { matches: knockoutMatches, unplaced: knockoutUnplaced } = placeKnockoutIntoCells({
|
|
411
|
+
cells: competition.knockout_cells ?? [],
|
|
412
|
+
brackets: this.bracketsFor(competition),
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const divisionIds = this.divisionIdsFor(competition)
|
|
416
|
+
|
|
417
|
+
this.matches = this.matches.filter(match => !divisionIds.has(match.division_id))
|
|
418
|
+
this.matches.push(...matches)
|
|
419
|
+
this.matches.push(...knockoutMatches)
|
|
420
|
+
|
|
421
|
+
this.unscheduledByCompetition.set(
|
|
422
|
+
competitionId, unplaced.length + knockoutUnplaced.length
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
this.dispatchEvent(new CustomEvent('tennis-schedule:changed', {
|
|
426
|
+
bubbles: true,
|
|
427
|
+
detail: { matches: this.matches },
|
|
428
|
+
}))
|
|
429
|
+
|
|
430
|
+
const scheduled = matches.length + knockoutMatches.length
|
|
431
|
+
|
|
432
|
+
this.dispatchEvent(new CustomEvent('tennis-schedule:generated', {
|
|
433
|
+
bubbles: true,
|
|
434
|
+
detail: {
|
|
435
|
+
competition: competition.name,
|
|
436
|
+
scheduled,
|
|
437
|
+
unplaced: unplaced.length + knockoutUnplaced.length,
|
|
438
|
+
},
|
|
439
|
+
}))
|
|
440
|
+
|
|
441
|
+
this.render()
|
|
442
|
+
|
|
443
|
+
return scheduled > 0
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Unschedules the whole event, or just the day on screen.
|
|
448
|
+
*
|
|
449
|
+
* Sent as a delete rather than as an empty save: which rows to drop
|
|
450
|
+
* follows from the event and the date, and a board that holds one
|
|
451
|
+
* day at a time could not send the rest of them to be replaced.
|
|
452
|
+
*/
|
|
453
|
+
clearAll() {
|
|
454
|
+
return this.clear(this.getConfig()['clear-api'], null, () => {
|
|
455
|
+
this.matches = []
|
|
456
|
+
this.unscheduledByCompetition = new Map()
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
clearDay(date) {
|
|
461
|
+
if (!date) {
|
|
462
|
+
return Promise.resolve(false)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return this.clear(`${this.getConfig()['day-api']}/${date}`, date, () => {
|
|
466
|
+
this.matches = this.matches.filter(match => match.date !== date)
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @param {string} url
|
|
472
|
+
* @param {?string} date the date cleared, or null for the whole
|
|
473
|
+
* event - said in the event a page toasts.
|
|
474
|
+
* @param {Function} apply drops the cleared matches from the board's
|
|
475
|
+
* own copy, once the server has dropped them from the database.
|
|
476
|
+
* @returns {Promise<boolean>}
|
|
477
|
+
*/
|
|
478
|
+
async clear(url, date, apply) {
|
|
479
|
+
const config = this.getConfig()
|
|
480
|
+
|
|
481
|
+
const response = await fetch(url, {
|
|
482
|
+
method: 'DELETE',
|
|
483
|
+
headers: {
|
|
484
|
+
Accept: 'application/json',
|
|
485
|
+
'X-CSRF-TOKEN': config['csrf-token'],
|
|
486
|
+
},
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
if (!response.ok) {
|
|
490
|
+
this.dispatchEvent(new CustomEvent('tennis-schedule:save-error', {
|
|
491
|
+
bubbles: true,
|
|
492
|
+
detail: await response.json().catch(() => ({})),
|
|
493
|
+
}))
|
|
494
|
+
return false
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
apply()
|
|
498
|
+
|
|
499
|
+
await this.refreshCounts()
|
|
500
|
+
|
|
501
|
+
this.dispatchEvent(new CustomEvent('tennis-schedule:changed', {
|
|
502
|
+
bubbles: true,
|
|
503
|
+
detail: { matches: this.matches },
|
|
504
|
+
}))
|
|
505
|
+
|
|
506
|
+
this.dispatchEvent(new CustomEvent('tennis-schedule:cleared', {
|
|
507
|
+
bubbles: true,
|
|
508
|
+
detail: { date },
|
|
509
|
+
}))
|
|
510
|
+
|
|
511
|
+
this.render()
|
|
512
|
+
|
|
513
|
+
return true
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* How many matches this competition can actually produce.
|
|
518
|
+
*
|
|
519
|
+
* Asked of the placer rather than worked out again here: a short
|
|
520
|
+
* group is played at the size it was planned at, so its byes count,
|
|
521
|
+
* and a knockout bye is never played, so it does not. Counting them
|
|
522
|
+
* the other way round had a competition report more matches
|
|
523
|
+
* scheduled than it has.
|
|
524
|
+
*/
|
|
525
|
+
expectedMatchCount(competition) {
|
|
526
|
+
const groups = competition.groups.reduce(
|
|
527
|
+
(total, group) => total + groupFixtures({
|
|
528
|
+
teamIds: group.team_ids, groupSize: group.pool_size,
|
|
529
|
+
}).filter(fixture => fixture.playable).length,
|
|
530
|
+
0
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
const knockout = (competition.brackets ?? []).reduce(
|
|
534
|
+
(total, bracket) =>
|
|
535
|
+
total + bracket.matches.filter(match => !isBye(match)).length,
|
|
536
|
+
0
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return groups + knockout
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* court_id != null matters: a knockout shell exists as a match row
|
|
544
|
+
* before it has been placed anywhere, so counting rows alone would
|
|
545
|
+
* report a full schedule while cells sit empty.
|
|
546
|
+
*/
|
|
547
|
+
/**
|
|
548
|
+
* A competition's tally, as the legend reads it.
|
|
549
|
+
*
|
|
550
|
+
* The server counts what is stored, because a board holding one day
|
|
551
|
+
* at a time cannot: it would report a week's schedule as the one day
|
|
552
|
+
* it happens to have. Once a competition has been fetched whole the
|
|
553
|
+
* board counts its own copy instead, so a generate that has not been
|
|
554
|
+
* saved yet still shows.
|
|
555
|
+
*
|
|
556
|
+
* @param {Object} competition
|
|
557
|
+
* @returns {{expected: number, scheduled: number}}
|
|
558
|
+
*/
|
|
559
|
+
countsFor(competition) {
|
|
560
|
+
if (!competition.loaded) {
|
|
561
|
+
return competition.counts ?? { expected: 0, scheduled: 0 }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
expected: this.expectedMatchCount(competition),
|
|
566
|
+
scheduled: this.scheduledMatchCount(competition),
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Re-reads the stored tallies after a save or a clear changed them.
|
|
572
|
+
* The shell is the cheap call, and nothing else it returns has
|
|
573
|
+
* moved.
|
|
574
|
+
*/
|
|
575
|
+
async refreshCounts() {
|
|
576
|
+
const data = await this.fetchJSON(this.getConfig()['shell-api'])
|
|
577
|
+
|
|
578
|
+
if (!data) {
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const counts = new Map(
|
|
583
|
+
data.competitions.map(competition => [competition.id, competition.counts])
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
for (const competition of this.state.competitions) {
|
|
587
|
+
competition.counts = counts.get(competition.id) ?? competition.counts
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
scheduledMatchCount(competition) {
|
|
592
|
+
const divisionIds = this.divisionIdsFor(competition)
|
|
593
|
+
return this.matches.filter(
|
|
594
|
+
match => divisionIds.has(match.division_id) && match.court_id != null
|
|
595
|
+
).length
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
matchAt(courtId, date, hour) {
|
|
599
|
+
return this.matches.find(match =>
|
|
600
|
+
match.court_id === courtId && match.date === date && match.hour === hour
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The grid only needs to cover the days/hours a competition actually
|
|
606
|
+
* has capacity cells for - not the full event range, most of which
|
|
607
|
+
* would just be an empty grid here.
|
|
608
|
+
*/
|
|
609
|
+
allCells(competition) {
|
|
610
|
+
return [...competition.cells, ...(competition.knockout_cells ?? [])]
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The event's days, as Planning draws them.
|
|
615
|
+
*
|
|
616
|
+
* Falls back to the days the planned cells mention, for a backend
|
|
617
|
+
* that sends no grid of its own.
|
|
618
|
+
*/
|
|
619
|
+
dates() {
|
|
620
|
+
if (this.state.days?.length) {
|
|
621
|
+
return [...this.state.days]
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const dates = new Set()
|
|
625
|
+
for (const competition of this.state.competitions) {
|
|
626
|
+
for (const cell of this.allCells(competition)) {
|
|
627
|
+
dates.add(cell.date)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return [...dates].sort()
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The day to open on: the first with anything planned in it.
|
|
635
|
+
*
|
|
636
|
+
* The tabs cover the whole event, as Planning's do, but an event
|
|
637
|
+
* usually opens before play does - landing on a day with nothing in
|
|
638
|
+
* it reads as a board that failed to load. The shell says which days
|
|
639
|
+
* have anything on them, so the day to open on is known before any
|
|
640
|
+
* of them has been fetched.
|
|
641
|
+
*
|
|
642
|
+
* @param {string[]} dates
|
|
643
|
+
* @returns {string}
|
|
644
|
+
*/
|
|
645
|
+
firstPlannedDate(dates) {
|
|
646
|
+
const planned = new Set(this.state.planned_days ?? [])
|
|
647
|
+
|
|
648
|
+
if (planned.size === 0) {
|
|
649
|
+
for (const competition of this.state.competitions) {
|
|
650
|
+
for (const cell of this.allCells(competition)) {
|
|
651
|
+
planned.add(cell.date)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return dates.find(date => planned.has(date)) ?? dates[0]
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* The rows of one day, worked out the way Planning works them out -
|
|
661
|
+
* per competition, from its own sessions. A court and hour with
|
|
662
|
+
* nothing planned in it is drawn blocked: nothing may be scheduled
|
|
663
|
+
* where nothing was planned.
|
|
664
|
+
*/
|
|
665
|
+
hoursFor(date) {
|
|
666
|
+
return rowsForDate({
|
|
667
|
+
date,
|
|
668
|
+
competitions: this.state.competitions ?? [],
|
|
669
|
+
cells: this.allCellsWithCompetition(),
|
|
670
|
+
}).map(row => row.key)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Every planned cell, carrying the competition it belongs to -
|
|
675
|
+
* which the row logic needs and the payload nests instead.
|
|
676
|
+
*/
|
|
677
|
+
allCellsWithCompetition() {
|
|
678
|
+
return (this.state.competitions ?? []).flatMap(competition =>
|
|
679
|
+
this.allCells(competition).map(cell => ({
|
|
680
|
+
...cell, competition_id: competition.id,
|
|
681
|
+
}))
|
|
682
|
+
)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
courtsFor(date, hour) {
|
|
686
|
+
const courtIds = new Set()
|
|
687
|
+
for (const competition of this.state.competitions) {
|
|
688
|
+
for (const cell of this.allCells(competition)) {
|
|
689
|
+
if (cell.date === date && cell.slot === hour) {
|
|
690
|
+
courtIds.add(cell.court_id)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return this.state.courts.filter(court => courtIds.has(court.id))
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
render() {
|
|
698
|
+
this.$('[data-status]').hidden = true
|
|
699
|
+
this.$('[data-clear-all]').hidden = false
|
|
700
|
+
this.$('[data-clear-day]').hidden = false
|
|
701
|
+
this.$('[data-legend]').hidden = false
|
|
702
|
+
this.$('[data-save]').hidden = false
|
|
703
|
+
this.$('[data-view-toggle]').hidden = false
|
|
704
|
+
|
|
705
|
+
// The schedule as data, for reading rather than looking at - the
|
|
706
|
+
// same toggle Planning has.
|
|
707
|
+
const showData = this.$('[data-show-data]').checked
|
|
708
|
+
const data = this.$('[data-schedule-data]')
|
|
709
|
+
|
|
710
|
+
data.hidden = !showData
|
|
711
|
+
this.$('[data-schedule]').hidden = showData
|
|
712
|
+
|
|
713
|
+
if (showData) {
|
|
714
|
+
data.textContent = JSON.stringify(this.scheduleData(), null, 2)
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
this.renderLegend()
|
|
719
|
+
this.renderSchedule()
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* The schedule as plain data: a day holds courts, a court holds
|
|
724
|
+
* hours, an hour holds what is played in it.
|
|
725
|
+
*/
|
|
726
|
+
scheduleData() {
|
|
727
|
+
return this.dates().map(date => ({
|
|
728
|
+
date,
|
|
729
|
+
hours: this.hoursFor(date).map(hour => ({
|
|
730
|
+
hour,
|
|
731
|
+
courts: this.courtsFor(date, hour).map(court => {
|
|
732
|
+
const match = this.matchAt(court.id, date, hour)
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
court: court.name,
|
|
736
|
+
division: match
|
|
737
|
+
? this.divisionNamesById[match.division_id] ?? null
|
|
738
|
+
: null,
|
|
739
|
+
group: match ? this.groupsById[match.group_id]?.name ?? null : null,
|
|
740
|
+
round: match?.round ?? null,
|
|
741
|
+
home: match
|
|
742
|
+
? this.sideMembers(match.home_team_id, match.visitor_team_id)
|
|
743
|
+
.join(', ')
|
|
744
|
+
: null,
|
|
745
|
+
visitor: match
|
|
746
|
+
? this.sideMembers(match.visitor_team_id, match.home_team_id)
|
|
747
|
+
.join(', ')
|
|
748
|
+
: null,
|
|
749
|
+
}
|
|
750
|
+
}),
|
|
751
|
+
})),
|
|
752
|
+
}))
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
renderLegend() {
|
|
756
|
+
const legend = this.$('[data-legend]')
|
|
757
|
+
legend.replaceChildren()
|
|
758
|
+
|
|
759
|
+
for (const competition of this.state.competitions) {
|
|
760
|
+
if (this.countsFor(competition).expected === 0) {
|
|
761
|
+
continue
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const item = document.createElement('li')
|
|
765
|
+
item.dataset.competitionId = competition.id
|
|
766
|
+
|
|
767
|
+
const name = document.createElement('span')
|
|
768
|
+
name.dataset.legendName = ''
|
|
769
|
+
name.textContent = competition.name
|
|
770
|
+
|
|
771
|
+
const count = document.createElement('span')
|
|
772
|
+
count.dataset.legendCount = ''
|
|
773
|
+
const counts = this.countsFor(competition)
|
|
774
|
+
count.textContent = `${counts.scheduled}/${counts.expected} matches scheduled`
|
|
775
|
+
|
|
776
|
+
item.append(name, count)
|
|
777
|
+
|
|
778
|
+
const unscheduled = this.unscheduledByCompetition.get(competition.id)
|
|
779
|
+
if (unscheduled) {
|
|
780
|
+
const warning = document.createElement('span')
|
|
781
|
+
warning.dataset.legendWarning = ''
|
|
782
|
+
warning.textContent = `${unscheduled} could not fit - allocate more capacity in Planning`
|
|
783
|
+
item.appendChild(warning)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const button = document.createElement('button')
|
|
787
|
+
button.type = 'button'
|
|
788
|
+
button.dataset.legendGenerate = ''
|
|
789
|
+
button.textContent = 'Generate schedule'
|
|
790
|
+
button.addEventListener('click', event => {
|
|
791
|
+
withBusyButton(
|
|
792
|
+
event.currentTarget, () => this.generate(competition.id), GENERATING
|
|
793
|
+
)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
item.appendChild(button)
|
|
797
|
+
legend.appendChild(item)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
renderSchedule() {
|
|
802
|
+
const container = this.$('[data-schedule]')
|
|
803
|
+
const activeDate = container.querySelector('semtic-tabbed')?.getAttribute('current')
|
|
804
|
+
container.replaceChildren()
|
|
805
|
+
|
|
806
|
+
const dates = this.dates()
|
|
807
|
+
|
|
808
|
+
if (dates.length === 0) {
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const tabbed = document.createElement('semtic-tabbed')
|
|
813
|
+
const initial = dates.includes(activeDate)
|
|
814
|
+
? activeDate
|
|
815
|
+
: this.firstPlannedDate(dates)
|
|
816
|
+
|
|
817
|
+
tabbed.setAttribute('current', initial)
|
|
818
|
+
|
|
819
|
+
for (const date of dates) {
|
|
820
|
+
const tab = document.createElement('semtic-tab')
|
|
821
|
+
tab.setAttribute('tab', date)
|
|
822
|
+
tab.setAttribute('label', date)
|
|
823
|
+
tabbed.appendChild(tab)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
this.buildDay(tabbed, initial)
|
|
827
|
+
|
|
828
|
+
tabbed.addEventListener(
|
|
829
|
+
'semtic-tabbed:activated', event => this.buildDay(tabbed, event.detail.tab)
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
container.appendChild(tabbed)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Draws one day's grid, fetching that day first if this is the first
|
|
837
|
+
* time it has been opened, and drawing it again when that arrives.
|
|
838
|
+
* What is drawn in between is whatever the board already holds for
|
|
839
|
+
* the day - a competition generated earlier holds every day of its
|
|
840
|
+
* own, so it shows at once.
|
|
841
|
+
*
|
|
842
|
+
* @param {HTMLElement} tabbed
|
|
843
|
+
* @param {string} date
|
|
844
|
+
*/
|
|
845
|
+
buildDay(tabbed, date) {
|
|
846
|
+
const tab = tabbed.querySelector(`:scope > semtic-tab[tab="${date}"]`)
|
|
847
|
+
|
|
848
|
+
if (!tab) {
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
tab.replaceChildren(this.tableForDay(date))
|
|
853
|
+
|
|
854
|
+
if (this.loadedDates.has(date)) {
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
this.fetchDay(date).then(applied => {
|
|
859
|
+
if (applied && tab.isConnected) {
|
|
860
|
+
tab.replaceChildren(this.tableForDay(date))
|
|
861
|
+
}
|
|
862
|
+
})
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
tableForDay(date) {
|
|
866
|
+
const hours = this.hoursFor(date)
|
|
867
|
+
const courts = this.state.courts
|
|
868
|
+
|
|
869
|
+
const table = document.createElement('table')
|
|
870
|
+
table.dataset.day = date
|
|
871
|
+
|
|
872
|
+
const thead = document.createElement('thead')
|
|
873
|
+
const headRow = document.createElement('tr')
|
|
874
|
+
headRow.appendChild(document.createElement('th'))
|
|
875
|
+
for (const court of courts) {
|
|
876
|
+
const th = document.createElement('th')
|
|
877
|
+
th.dataset.courtId = court.id
|
|
878
|
+
th.textContent = court.name
|
|
879
|
+
headRow.appendChild(th)
|
|
880
|
+
}
|
|
881
|
+
thead.appendChild(headRow)
|
|
882
|
+
table.appendChild(thead)
|
|
883
|
+
|
|
884
|
+
const tbody = document.createElement('tbody')
|
|
885
|
+
for (const hour of hours) {
|
|
886
|
+
tbody.appendChild(this.gridRow(date, hour, courts))
|
|
887
|
+
}
|
|
888
|
+
table.appendChild(tbody)
|
|
889
|
+
|
|
890
|
+
return table
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
gridRow(date, hour, courts) {
|
|
894
|
+
const row = document.createElement('tr')
|
|
895
|
+
row.dataset.slot = hour
|
|
896
|
+
|
|
897
|
+
const heading = document.createElement('th')
|
|
898
|
+
heading.dataset.slotLabel = ''
|
|
899
|
+
heading.textContent = hour
|
|
900
|
+
row.appendChild(heading)
|
|
901
|
+
|
|
902
|
+
const availableCourts = new Set(this.courtsFor(date, hour).map(court => court.id))
|
|
903
|
+
|
|
904
|
+
for (const court of courts) {
|
|
905
|
+
const cell = document.createElement('td')
|
|
906
|
+
|
|
907
|
+
if (!availableCourts.has(court.id)) {
|
|
908
|
+
cell.dataset.blocked = ''
|
|
909
|
+
row.appendChild(cell)
|
|
910
|
+
continue
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
cell.dataset.courtId = court.id
|
|
914
|
+
|
|
915
|
+
const match = this.matchAt(court.id, date, hour)
|
|
916
|
+
|
|
917
|
+
cell.appendChild(match ? this.matchLabel(match) : this.emptySlotLabel())
|
|
918
|
+
|
|
919
|
+
row.appendChild(cell)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return row
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* [name 1] [opp 1]
|
|
927
|
+
* [name 2] vs [opp 2]
|
|
928
|
+
* [division / group]
|
|
929
|
+
*
|
|
930
|
+
* A team's display name is one name for singles, or its 2 members
|
|
931
|
+
* comma-separated for doubles (see MatchDaySchedulingController's
|
|
932
|
+
* teamNames) - split back out so each member gets their own line,
|
|
933
|
+
* lined up against their counterpart on the other team.
|
|
934
|
+
*/
|
|
935
|
+
/**
|
|
936
|
+
* A match as a cell reads it: what is being played, then who is
|
|
937
|
+
* playing it.
|
|
938
|
+
*
|
|
939
|
+
* The group or the round comes first and stands on its own, because
|
|
940
|
+
* that is what a director scans a column for. It carries the
|
|
941
|
+
* division's own hue, the one Planning draws it in, so two cells of
|
|
942
|
+
* the same division read as the same division without either of them
|
|
943
|
+
* being read.
|
|
944
|
+
*/
|
|
945
|
+
matchLabel(match) {
|
|
946
|
+
const group = this.groupsById[match.group_id]
|
|
947
|
+
|
|
948
|
+
// A knockout match has no group, so it labels itself by its round
|
|
949
|
+
// instead.
|
|
950
|
+
const badge = group ? group.name : match.round
|
|
951
|
+
const divisionName = group
|
|
952
|
+
? group.division_name
|
|
953
|
+
: this.divisionNamesById[match.division_id]
|
|
954
|
+
|
|
955
|
+
const container = document.createElement('div')
|
|
956
|
+
container.dataset.cellMatch = ''
|
|
957
|
+
|
|
958
|
+
const hue = this.divisionHue(match.division_id)
|
|
959
|
+
|
|
960
|
+
if (hue != null) {
|
|
961
|
+
container.style.setProperty('--division-hue', hue)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
container.append(
|
|
965
|
+
this.metaLabel(badge, divisionName),
|
|
966
|
+
this.teamsLabel(match)
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
return container
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
metaLabel(badge, divisionName) {
|
|
973
|
+
const meta = document.createElement('div')
|
|
974
|
+
meta.dataset.cellMeta = ''
|
|
975
|
+
|
|
976
|
+
const badgeElement = document.createElement('span')
|
|
977
|
+
badgeElement.dataset.cellBadge = ''
|
|
978
|
+
badgeElement.textContent = badge ?? ''
|
|
979
|
+
|
|
980
|
+
const division = document.createElement('span')
|
|
981
|
+
division.dataset.cellDivision = ''
|
|
982
|
+
division.textContent = divisionName ?? ''
|
|
983
|
+
|
|
984
|
+
meta.append(badgeElement, division)
|
|
985
|
+
|
|
986
|
+
return meta
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* The two sides, one column each, with the vs between them.
|
|
991
|
+
*
|
|
992
|
+
* Every name is placed on its own row, so a doubles pair lines up
|
|
993
|
+
* against its counterpart. The vs sits on the middle row where there
|
|
994
|
+
* is one and between the two middle rows where there is not, which
|
|
995
|
+
* it does by spanning them all and centring itself.
|
|
996
|
+
*/
|
|
997
|
+
teamsLabel(match) {
|
|
998
|
+
const home = this.sideMembers(match.home_team_id, match.visitor_team_id)
|
|
999
|
+
const visitor = this.sideMembers(match.visitor_team_id, match.home_team_id)
|
|
1000
|
+
|
|
1001
|
+
const teams = document.createElement('div')
|
|
1002
|
+
teams.dataset.cellTeams = ''
|
|
1003
|
+
|
|
1004
|
+
const rows = Math.max(home.length, visitor.length, 1)
|
|
1005
|
+
teams.dataset.rows = rows
|
|
1006
|
+
|
|
1007
|
+
for (const [index, name] of home.entries()) {
|
|
1008
|
+
teams.appendChild(this.playerLabel('cellName', name, index + 1))
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
for (const [index, name] of visitor.entries()) {
|
|
1012
|
+
teams.appendChild(this.playerLabel('cellOpp', name, index + 1))
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const vs = document.createElement('span')
|
|
1016
|
+
vs.dataset.cellVs = ''
|
|
1017
|
+
vs.textContent = 'vs'
|
|
1018
|
+
vs.style.gridRow = rows % 2 === 0
|
|
1019
|
+
? '1 / -1'
|
|
1020
|
+
: String(Math.ceil(rows / 2))
|
|
1021
|
+
|
|
1022
|
+
teams.appendChild(vs)
|
|
1023
|
+
|
|
1024
|
+
return teams
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* One name, on its own row of its side's column.
|
|
1029
|
+
*
|
|
1030
|
+
* The flag in front of a name is drawn as an image where the
|
|
1031
|
+
* platform would draw it as two letters instead, which is what
|
|
1032
|
+
* Windows does with every flag. `flag-svg` decides when: always,
|
|
1033
|
+
* never, or windows.
|
|
1034
|
+
*
|
|
1035
|
+
* @param {string} side cellName or cellOpp
|
|
1036
|
+
* @param {string} name
|
|
1037
|
+
* @param {number} row
|
|
1038
|
+
* @returns {HTMLElement}
|
|
1039
|
+
*/
|
|
1040
|
+
playerLabel(side, name, row) {
|
|
1041
|
+
const element = document.createElement('span')
|
|
1042
|
+
|
|
1043
|
+
element.dataset[side] = ''
|
|
1044
|
+
element.style.gridRow = String(row)
|
|
1045
|
+
|
|
1046
|
+
setNameText(element, name, this.getConfig()['flag-svg'])
|
|
1047
|
+
|
|
1048
|
+
return element
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* The hue this division is drawn in, the one Planning draws it in.
|
|
1053
|
+
*
|
|
1054
|
+
* @see division-hues.mjs
|
|
1055
|
+
*/
|
|
1056
|
+
divisionHue(divisionId) {
|
|
1057
|
+
this._divisionHues ??= divisionHues(this.state.competitions)
|
|
1058
|
+
|
|
1059
|
+
const hue = this._divisionHues.get(divisionId)
|
|
1060
|
+
|
|
1061
|
+
if (hue != null) {
|
|
1062
|
+
return hue
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const index = this.state.competitions.findIndex(
|
|
1066
|
+
competition => this.divisionIdsFor(competition).has(divisionId)
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
return competitionHue(index, this.state.competitions.length)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* A planned cell with no match to put in it. Planning reserves full
|
|
1074
|
+
* capacity while Scheduling fills actual matches, so the leftover
|
|
1075
|
+
* cells are expected, not an error.
|
|
1076
|
+
*/
|
|
1077
|
+
emptySlotLabel() {
|
|
1078
|
+
const container = document.createElement('div')
|
|
1079
|
+
container.dataset.cellMatch = ''
|
|
1080
|
+
container.dataset.cellEmpty = ''
|
|
1081
|
+
|
|
1082
|
+
const teams = document.createElement('div')
|
|
1083
|
+
teams.dataset.cellTeams = ''
|
|
1084
|
+
teams.dataset.rows = 1
|
|
1085
|
+
|
|
1086
|
+
const name = document.createElement('span')
|
|
1087
|
+
name.dataset.cellName = ''
|
|
1088
|
+
name.textContent = '[empty]'
|
|
1089
|
+
name.style.gridRow = '1'
|
|
1090
|
+
|
|
1091
|
+
const vs = document.createElement('span')
|
|
1092
|
+
vs.dataset.cellVs = ''
|
|
1093
|
+
vs.textContent = 'vs'
|
|
1094
|
+
vs.style.gridRow = '1'
|
|
1095
|
+
|
|
1096
|
+
const opp = document.createElement('span')
|
|
1097
|
+
opp.dataset.cellOpp = ''
|
|
1098
|
+
opp.textContent = '[empty]'
|
|
1099
|
+
opp.style.gridRow = '1'
|
|
1100
|
+
|
|
1101
|
+
teams.append(name, opp, vs)
|
|
1102
|
+
container.appendChild(teams)
|
|
1103
|
+
|
|
1104
|
+
return container
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* The names on one side of a fixture.
|
|
1110
|
+
*
|
|
1111
|
+
* An empty side against a team is a bye and says so: a short group
|
|
1112
|
+
* is played at the size it was planned at, and a cell reading "A vs
|
|
1113
|
+
* bye" explains itself where a blank one leaves a director guessing.
|
|
1114
|
+
* Both sides empty is a fixture waiting on winners, and says
|
|
1115
|
+
* nothing yet.
|
|
1116
|
+
*/
|
|
1117
|
+
sideMembers(teamId, opponentId) {
|
|
1118
|
+
if (teamId === null || teamId === undefined) {
|
|
1119
|
+
return opponentId === null || opponentId === undefined ? [] : ['bye']
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return this.teamMembers(teamId)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
teamMembers(teamId) {
|
|
1126
|
+
const name = this.teamNames[teamId]
|
|
1127
|
+
|
|
1128
|
+
if (!name) {
|
|
1129
|
+
return []
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return name.split(',').map(part => part.trim())
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async save() {
|
|
1136
|
+
return withBusyButton(this.$('[data-save]'), async () => {
|
|
1137
|
+
|
|
1138
|
+
const config = this.getConfig()
|
|
1139
|
+
|
|
1140
|
+
const generated = this.generatedDivisionIds()
|
|
1141
|
+
|
|
1142
|
+
const matches = this.matches
|
|
1143
|
+
.filter(match => generated.has(match.division_id))
|
|
1144
|
+
.map(match => ({
|
|
1145
|
+
id: match.id,
|
|
1146
|
+
round: match.round,
|
|
1147
|
+
division_id: match.division_id,
|
|
1148
|
+
group_id: match.group_id,
|
|
1149
|
+
slot: match.slot,
|
|
1150
|
+
court_id: match.court_id,
|
|
1151
|
+
date: match.date,
|
|
1152
|
+
hour: match.hour,
|
|
1153
|
+
home_team_id: match.home_team_id,
|
|
1154
|
+
visitor_team_id: match.visitor_team_id,
|
|
1155
|
+
}))
|
|
1156
|
+
|
|
1157
|
+
const response = await fetch(config['save-api'], {
|
|
1158
|
+
method: 'POST',
|
|
1159
|
+
headers: {
|
|
1160
|
+
Accept: 'application/json',
|
|
1161
|
+
'Content-Type': 'application/json',
|
|
1162
|
+
'X-CSRF-TOKEN': config['csrf-token'],
|
|
1163
|
+
},
|
|
1164
|
+
body: JSON.stringify({ matches }),
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
const body = await response.json()
|
|
1168
|
+
|
|
1169
|
+
if (!response.ok) {
|
|
1170
|
+
this.dispatchEvent(new CustomEvent('tennis-schedule:save-error', {
|
|
1171
|
+
bubbles: true,
|
|
1172
|
+
detail: body,
|
|
1173
|
+
}))
|
|
1174
|
+
return false
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
await this.refreshCounts()
|
|
1178
|
+
this.render()
|
|
1179
|
+
|
|
1180
|
+
// An acknowledgement is enough - the board already holds what it
|
|
1181
|
+
// sent. Returning the whole schedule and re-rendering from it cost
|
|
1182
|
+
// more than the save.
|
|
1183
|
+
this.dispatchEvent(new CustomEvent('tennis-schedule:save', {
|
|
1184
|
+
bubbles: true,
|
|
1185
|
+
detail: { saved: body },
|
|
1186
|
+
}))
|
|
1187
|
+
|
|
1188
|
+
return true
|
|
1189
|
+
}, SAVING)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
}
|
|
1193
|
+
|