@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,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
+