@opndev/tennis-match-center 0.0.1 → 0.0.2

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,1691 @@
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
+
7
+ import * as facts from '../gameday-facts.mjs'
8
+ import { GamedayDialog } from '../gameday-dialog.mjs'
9
+ import { whatIsNext } from '../gameday-next.mjs'
10
+ import { scoreField, teamCell, textTag } from '../gameday-parts.mjs'
11
+
12
+ import { TennisGamedayCardElement } from './gameday-card.mjs'
13
+ import { TennisGamedayListElement } from './gameday-list.mjs'
14
+
15
+ /**
16
+ * An official is reading the line they are typing into. Focus that
17
+ * scrolls the board out from under them is worse than focus they have
18
+ * to find, so nothing here ever moves the page to follow it.
19
+ */
20
+ const NO_SCROLL = { preventScroll: true }
21
+
22
+ /**
23
+ * <tennis-gameday-board shell-api="..." day-api="...">
24
+ *
25
+ * The officials desk: one day, read down as a list of start times,
26
+ * each holding the matches that start then. A line is the two sides, a
27
+ * score per set, the court, and a button to start it.
28
+ *
29
+ * The day is fetched whole and split client-side:
30
+ *
31
+ * [finished]
32
+ * [started]
33
+ * [pending]
34
+ *
35
+ * A meta component: it owns the day, what is written back, and what
36
+ * the dialogs ask. It draws no line and folds no block.
37
+ *
38
+ * <tennis-gameday-list> one state, grouped by start time
39
+ * <tennis-gameday-card> one match, and what was asked of it
40
+ * GamedayDialog one dialog, and when it is open
41
+ *
42
+ * A card reports; the board decides. Nothing here reaches into a line
43
+ * to find out what was pressed.
44
+ */
45
+ export class TennisGamedayBoardElement extends HTMLElementSugar {
46
+ static tag = 'tennis-gameday-board'
47
+ static renderGuardSelector = '[data-toolbar]'
48
+
49
+ static REASONS = [
50
+ ['walkover', 'Walkover'],
51
+ ['injury', 'Injury'],
52
+ ['no_show', 'No show'],
53
+ ['other', 'Other'],
54
+ ]
55
+
56
+ /**
57
+ * What a match that was not played out is called afterwards. 'other'
58
+ * is a real result with a note against it rather than a named one.
59
+ */
60
+ static REASON_STATUS = {
61
+ walkover: 'walkover',
62
+ injury: 'retired',
63
+ no_show: 'no_show',
64
+
65
+ // A walkover too. 'completed' is the one status a line draws no
66
+ // marker for, so a match nobody played sat in the day looking
67
+ // exactly like a match somebody did. The note says what happened.
68
+ other: 'walkover',
69
+ }
70
+
71
+ static FORFEIT_GAMES = 7
72
+
73
+ /** Blocks open on the first draw, by section. */
74
+ static OPENS_WITH = { finished: 0, pending: 2 }
75
+
76
+ static attributeDefs = {
77
+ 'shell-api': {},
78
+ 'day-api': {},
79
+ 'match-api': {},
80
+ 'csrf-token': {},
81
+
82
+ // A width, as CSS says one: below it the day opens folded. Named
83
+ // by the host, because which width is small is the host's layout
84
+ // and not this board's business. Left off, nothing folds.
85
+ 'fold-below': {},
86
+ }
87
+
88
+ static HtmlTemplate = ['tennis-gameday-board-template', function () {
89
+ return this.tpl(`
90
+ <p data-status>Loading…</p>
91
+ <nav data-toolbar hidden>
92
+ <semtic-select data-day label="Day" label-position="top"></semtic-select>
93
+ <button type="button" extra data-fold-all></button>
94
+ </nav>
95
+ <dl data-durations hidden></dl>
96
+ <tennis-gameday-list data-section="finished" heading="Finished" hidden></tennis-gameday-list>
97
+ <tennis-gameday-list data-section="started" heading="Started" hidden></tennis-gameday-list>
98
+ <tennis-gameday-list data-section="pending" heading="Pending" hidden></tennis-gameday-list>
99
+ <semtic-modal data-reopen-modal hidden
100
+ header="Change the score"
101
+ cancel-label="Leave it as it is"
102
+ confirm-label="Save the score"
103
+ show-confirm="true">
104
+ <p data-reopen-match></p>
105
+ <p data-reopen-said></p>
106
+ <button slot="actions" type="button" antagonist data-reopen-confirm>Re-open match</button>
107
+ </semtic-modal>
108
+ <semtic-modal data-unstart-modal hidden
109
+ header="Take the start back"
110
+ cancel-label="Leave it running"
111
+ confirm-label="Take it back"
112
+ show-confirm="true">
113
+ <p data-unstart-match></p>
114
+ <p data-unstart-said></p>
115
+ </semtic-modal>
116
+ <semtic-modal data-forfeit-modal hidden
117
+ header="End without a score"
118
+ cancel-label="Cancel"
119
+ confirm-label="Record it"
120
+ show-confirm="true">
121
+ <p data-forfeit-match></p>
122
+ <div data-forfeit-questions>
123
+ <semtic-fieldset data-forfeit-reason label="What happened"></semtic-fieldset>
124
+ <semtic-fieldset data-forfeit-winner label="Who won"></semtic-fieldset>
125
+ </div>
126
+ <div data-forfeit-note-field hidden>
127
+ <semtic-input data-forfeit-note
128
+ label="Note" label-position="top" maxlength="255"></semtic-input>
129
+ </div>
130
+
131
+ <semtic-checkbox data-forfeit-remaining
132
+ label="Forfeit every match that team has left"
133
+ label-position="right"></semtic-checkbox>
134
+ </semtic-modal>
135
+ <semtic-modal data-next-modal hidden
136
+ header="Next on court"
137
+ cancel-label="Back to the desk"
138
+ confirm-label="Play"
139
+ show-confirm="true">
140
+ <p data-next-said></p>
141
+ <p data-waiting-label></p>
142
+ <p data-waiting-head></p>
143
+ <p data-waiting-match></p>
144
+ <p data-queued-label></p>
145
+ <p data-queued-head></p>
146
+ <p data-queued-match></p>
147
+ </semtic-modal>
148
+ <semtic-modal data-busy-modal hidden
149
+ header="That court is taken"
150
+ cancel-label="Do not start"
151
+ confirm-label="Move and start"
152
+ show-confirm="true">
153
+ <p data-busy-said></p>
154
+ <p data-busy-match></p>
155
+ <semtic-select data-busy-select label="Play it on" label-position="top"></semtic-select>
156
+ <p data-busy-none hidden>Every court has a match on it.</p>
157
+ </semtic-modal>
158
+ <semtic-modal data-court-modal hidden
159
+ header="Change court"
160
+ cancel-label="Cancel"
161
+ confirm-label="Change court"
162
+ show-confirm="true">
163
+ <p data-court-modal-match></p>
164
+ <p data-court-modal-planned></p>
165
+ <semtic-select data-court-modal-select></semtic-select>
166
+ <button slot="actions" type="button" support data-court-reset>Use planned court</button>
167
+ </semtic-modal>
168
+ `)
169
+ }]
170
+
171
+ async connectedCallback() {
172
+ super.connectedCallback()
173
+
174
+ if (!this.hasRenderedShape()) {
175
+ this.appendChild(this.renderFromTemplate())
176
+ }
177
+
178
+ this.folded = this.folded ?? new Set()
179
+
180
+ if (!this._listenersBound) {
181
+ this._listenersBound = true
182
+
183
+ // The control, not the <semtic-select> around it: semtic echoes
184
+ // change from the host as well, and the day would be fetched
185
+ // twice for one pick.
186
+ this.$('[data-day]').addEventListener('change', event => {
187
+ if (event.target.tagName === 'SELECT') {
188
+ this.showDay(event.target.value)
189
+ }
190
+ })
191
+
192
+ this.$('[data-fold-all]').addEventListener('click', () => {
193
+ this.foldAll(this.anyOpen())
194
+ })
195
+
196
+ // A card says what was asked of it and the board decides what
197
+ // happens. Nothing here reaches into a line to find out what was
198
+ // pressed; that is the card's own business and it reports it.
199
+ this.addEventListener('gameday-card:score', event => {
200
+ this.recordScore(event.detail)
201
+ })
202
+
203
+ this.addEventListener('gameday-card:court', event => {
204
+ this.courtDialog.open(event.detail.match)
205
+ })
206
+
207
+ this.addEventListener('gameday-card:start', event => {
208
+ this.toggleStarted(event.detail.match)
209
+ })
210
+
211
+ this.addEventListener('gameday-card:action', event => {
212
+ this.toggleFinished(event.detail.match)
213
+ })
214
+
215
+ this.bindDialogs()
216
+ }
217
+
218
+ this.shell = null
219
+ this.day = null
220
+
221
+ const shell = await this.fetchJSON(this.getConfig()['shell-api'])
222
+
223
+ if (!shell) {
224
+ return
225
+ }
226
+
227
+ if (!Array.isArray(shell.match_days)) {
228
+ this.fail('The desk data came back in a shape this board does not understand.')
229
+
230
+ return
231
+ }
232
+
233
+ this.shell = shell
234
+
235
+ this.fillSelect(
236
+ this.$('[data-day]'),
237
+ shell.match_days.map(date => ({ value: date, label: date })),
238
+ shell.match_days[0] ?? ''
239
+ )
240
+
241
+ if (!shell.match_days.length) {
242
+ this.$('[data-status]').textContent = 'Nothing is scheduled yet.'
243
+
244
+ return
245
+ }
246
+
247
+ this.$('[data-toolbar]').hidden = false
248
+
249
+ await this.showDay(shell.match_days[0])
250
+ }
251
+
252
+ /**
253
+ * The four dialogs, each owning when it is open and which match it
254
+ * is about. The board says what goes in one and what to do when it
255
+ * is answered; opening it, closing it and tidying up after it is the
256
+ * dialog's own business.
257
+ */
258
+ bindDialogs() {
259
+ this.reopenDialog = new GamedayDialog(this.$('[data-reopen-modal]'), {
260
+ fill: match => this.fillReopen(match),
261
+ focus: () => this.$('[data-reopen-match] input'),
262
+ confirmed: () => this.saveCorrection(),
263
+ })
264
+
265
+ this.$('[data-reopen-confirm]').addEventListener('click', () => this.takeResultBack())
266
+
267
+ this.unstartDialog = new GamedayDialog(this.$('[data-unstart-modal]'), {
268
+ fill: match => this.fillUnstart(match),
269
+
270
+ // Cancel, not confirm: undoing a start throws away when the
271
+ // match went on, so the safe answer is the one under the finger.
272
+ focus: modal => modal.querySelector('[semtic-modal-cancel]'),
273
+ confirmed: () => this.confirmUnstart(),
274
+ })
275
+
276
+ this.forfeitDialog = new GamedayDialog(this.$('[data-forfeit-modal]'), {
277
+ fill: match => this.fillForfeit(match),
278
+ focus: modal => modal.querySelector('[data-forfeit-winner] input'),
279
+ confirmed: () => this.confirmForfeit(),
280
+ })
281
+
282
+ this.$('[data-forfeit-reason]').addEventListener('change', () => {
283
+ this.$('[data-forfeit-note-field]').hidden = this.forfeitReason() !== 'other'
284
+ })
285
+
286
+ this.nextDialog = new GamedayDialog(this.$('[data-next-modal]'), {
287
+ fill: match => this.fillNext(match),
288
+ focus: modal => modal.querySelector('[data-next-modal] input') ?? modal.querySelector('[semtic-modal-cancel]'),
289
+ confirmed: match => this.playPicked(this.effectiveCourtId(match)),
290
+ })
291
+
292
+ this.busyDialog = new GamedayDialog(this.$('[data-busy-modal]'), {
293
+ fill: match => this.fillBusy(match),
294
+ focus: modal => modal.querySelector('[data-busy-select] select')
295
+ ?? modal.querySelector('[semtic-modal-cancel]'),
296
+ confirmed: match => {
297
+ const courtId = Number(this.$('[data-busy-select]').value)
298
+
299
+ this.busyDialog.close()
300
+ this.playOn(match, courtId)
301
+ },
302
+ })
303
+
304
+ this.courtDialog = new GamedayDialog(this.$('[data-court-modal]'), {
305
+ fill: match => this.fillCourt(match),
306
+ focus: modal => modal.querySelector('[data-court-modal-select]'),
307
+ confirmed: match => {
308
+ this.moveCourt(match, Number(this.$('[data-court-modal-select]').value))
309
+ this.courtDialog.close()
310
+ },
311
+ })
312
+
313
+ this.$('[data-court-reset]').addEventListener('click', () => {
314
+ const match = this.courtDialog.match
315
+
316
+ this.courtDialog.close()
317
+ this.resetCourt(match)
318
+ })
319
+ }
320
+
321
+
322
+ /**
323
+ * Null when the request failed or a newer one has started.
324
+ *
325
+ * Never throws: an official needs to read an error, not a blank page.
326
+ *
327
+ * @param {string} url
328
+ * @returns {Promise<object|null>}
329
+ */
330
+ async fetchJSON(url) {
331
+ const requestId = (this._fetchGeneration = (this._fetchGeneration ?? 0) + 1)
332
+
333
+ let data = null
334
+
335
+ try {
336
+ const response = await fetch(url, {
337
+ headers: { Accept: 'application/json' },
338
+ })
339
+
340
+ if (!response.ok) {
341
+ this.fail(`Could not load the desk (${response.status}).`)
342
+
343
+ return null
344
+ }
345
+
346
+ data = await response.json()
347
+ } catch (error) {
348
+ this.fail('Could not reach the server.')
349
+
350
+ return null
351
+ }
352
+
353
+ return requestId === this._fetchGeneration ? data : null
354
+ }
355
+
356
+ /**
357
+ * @param {string} message
358
+ */
359
+ fail(message) {
360
+ const status = this.$('[data-status]')
361
+
362
+ status.hidden = false
363
+ status.dataset.failed = ''
364
+ status.textContent = message
365
+ }
366
+
367
+ /**
368
+ * Send one desk action and keep what the server says.
369
+ *
370
+ * The board has already drawn the change, because an official is
371
+ * standing there and a round trip is not their problem. If the
372
+ * server refuses it, the match goes back to what it was and the
373
+ * refusal is put on screen: a desk that silently kept a score the
374
+ * database does not have would be worse than a slow one.
375
+ *
376
+ * @param {object} match
377
+ * @param {object} body
378
+ * @param {object} before what the match was before the board drew
379
+ * the change, taken by the caller because by the time this runs
380
+ * the match already says what it hopes to say.
381
+ * @returns {Promise<boolean>}
382
+ */
383
+ async save(match, body, before) {
384
+ const config = this.getConfig()
385
+ const url = config['match-api']
386
+
387
+ if (!url) {
388
+ return true
389
+ }
390
+
391
+ let response
392
+
393
+ try {
394
+ response = await fetch(`${url}/${match.id}`, {
395
+ method: 'PATCH',
396
+ headers: {
397
+ 'Content-Type': 'application/json',
398
+ Accept: 'application/json',
399
+ 'X-CSRF-TOKEN': config['csrf-token'],
400
+ },
401
+ body: JSON.stringify(body),
402
+ })
403
+ } catch (error) {
404
+ return this.refuse(match, before, 'Could not reach the server. Nothing was saved.')
405
+ }
406
+
407
+ if (!response.ok) {
408
+ const problem = await response.json().catch(() => ({}))
409
+
410
+ if (problem.court_busy) {
411
+ return this.askAnotherCourt(match, before, problem.court_busy)
412
+ }
413
+
414
+ return this.refuse(match, before, this.reasonFrom(problem, response.status))
415
+ }
416
+
417
+ const data = await response.json().catch(() => ({}))
418
+
419
+ if (data.match) {
420
+ Object.assign(match, data.match)
421
+ }
422
+
423
+ this.render()
424
+
425
+ return true
426
+ }
427
+
428
+ /**
429
+ * @param {object} problem
430
+ * @param {number} status
431
+ * @returns {string}
432
+ */
433
+ reasonFrom(problem, status) {
434
+ const first = Object.values(problem.errors ?? {})[0]
435
+
436
+ if (Array.isArray(first) && first.length) {
437
+ return first[0]
438
+ }
439
+
440
+ return problem.message || `The desk could not save that (${status}).`
441
+ }
442
+
443
+ /**
444
+ * @param {object} match
445
+ * @param {object} before
446
+ * @returns {boolean}
447
+ */
448
+ refuse(match, before, message) {
449
+ Object.assign(match, before)
450
+
451
+ this.render()
452
+ this.fail(message)
453
+
454
+ return false
455
+ }
456
+
457
+ /**
458
+ * Put the line back and ask which free court to play on instead.
459
+ *
460
+ * @param {object} match
461
+ * @param {object} before
462
+ * @param {object} busy - the server's court_busy
463
+ * @returns {boolean}
464
+ */
465
+ askAnotherCourt(match, before, busy) {
466
+ Object.assign(match, before)
467
+
468
+ this.render()
469
+
470
+ this.busy = busy
471
+ this.busyDialog.open(match)
472
+
473
+ return false
474
+ }
475
+
476
+ /**
477
+ * @param {object} match
478
+ */
479
+ fillBusy(match) {
480
+ const free = this.freeCourtOptions(match)
481
+
482
+ const court = this.courtName(this.busy.court_id)
483
+
484
+ this.$('[data-busy-said]').textContent = this.busy.identifier
485
+ ? `${this.busy.identifier} is still on ${court}.`
486
+ : `${court} has a match on it.`
487
+
488
+ this.sayWhichMatch('[data-busy-match]', match)
489
+
490
+ this.$('[data-busy-select]').hidden = !free.length
491
+ this.$('[data-busy-none]').hidden = free.length > 0
492
+
493
+ // Nothing to confirm when there is nowhere to send them.
494
+ this.busyDialog.modal.setAttribute('show-confirm', free.length ? 'true' : 'false')
495
+
496
+ if (free.length) {
497
+ this.fillSelect(this.$('[data-busy-select]'), free, free[0].value)
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Move to `courtId`, then start. The start is sent the usual way, so
503
+ * a court taken in the meantime refuses it and asks again.
504
+ *
505
+ * @param {object|null} match
506
+ * @param {number} courtId
507
+ */
508
+ async playOn(match, courtId) {
509
+ if (!match || Number.isNaN(courtId)) {
510
+ return
511
+ }
512
+
513
+ await this.moveCourt(match, courtId)
514
+ await this.toggleStarted(match)
515
+ }
516
+
517
+ /**
518
+ * @param {string} date
519
+ */
520
+ async showDay(date) {
521
+ const day = await this.fetchJSON(`${this.getConfig()['day-api']}/${date}`)
522
+
523
+ if (!day) {
524
+ return
525
+ }
526
+
527
+ this.day = day
528
+ this.$('[data-day]').value = date
529
+
530
+ this.render()
531
+ }
532
+
533
+ /**
534
+ * @param {object} match
535
+ * @returns {string}
536
+ */
537
+ segmentOf(match) {
538
+ return facts.segmentOf(match)
539
+ }
540
+
541
+
542
+ render() {
543
+ const status = this.$('[data-status]')
544
+
545
+ status.hidden = true
546
+ delete status.dataset.failed
547
+
548
+ this.rememberFolds()
549
+
550
+ // Every list is replaced wholesale, which on its own drops the
551
+ // page back to wherever the shorter document ends. Ending a match
552
+ // moves its line to another section, so the board underneath an
553
+ // official genuinely changes height; putting the scroll back is
554
+ // what keeps the line they are working on where they left it.
555
+ const wasAt = this.scrollPosition()
556
+
557
+ this.renderDurations()
558
+
559
+ // Only ever on the first day drawn. After that the folds are the
560
+ // official's own and a redraw must not undo them.
561
+ const first = !this.drawn
562
+ const narrow = first && this.startsFolded()
563
+
564
+ this.drawn = true
565
+
566
+ for (const list of this.lists()) {
567
+ list.show(
568
+ this.day.matches.filter(
569
+ match => facts.segmentOf(match) === list.segment
570
+ ),
571
+ {
572
+ day: this.day,
573
+ shell: this.shell,
574
+ folded: this.folded,
575
+ openFirst: first ? this.opensWith(list.segment, narrow) : Infinity,
576
+ }
577
+ )
578
+ }
579
+
580
+ this.sayFoldAll()
581
+ this.scrollBackTo(wasAt)
582
+ }
583
+
584
+ /**
585
+ * Where the page is, or null when nothing can say.
586
+ *
587
+ * @returns {number|null}
588
+ */
589
+ scrollPosition() {
590
+ const view = this.ownerDocument?.defaultView
591
+
592
+ return typeof view?.scrollY === 'number' ? view.scrollY : null
593
+ }
594
+
595
+ /**
596
+ * @param {number|null} position
597
+ */
598
+ scrollBackTo(position) {
599
+ const view = this.ownerDocument?.defaultView
600
+
601
+ if (position === null || typeof view?.scrollTo !== 'function') {
602
+ return
603
+ }
604
+
605
+ if (view.scrollY !== position) {
606
+ view.scrollTo(view.scrollX ?? 0, position)
607
+ }
608
+ }
609
+
610
+ /**
611
+ * How long a match takes, as far as the day knows.
612
+ *
613
+ * Every figure, because they answer different questions. The average
614
+ * is pulled about by the match that went to three tiebreaks; the
615
+ * median is not. The mode is the length a match usually is, when
616
+ * there is one. The trimmed mean drops the shortest and the longest
617
+ * and averages what is left, which is what a director means by "how
618
+ * long does a match take, really".
619
+ */
620
+ renderDurations() {
621
+ const box = this.$('[data-durations]')
622
+ const stats = this.day?.durations?.day
623
+ const event = this.day?.durations?.event
624
+
625
+ if (!stats || !stats.count) {
626
+ box.hidden = true
627
+ box.replaceChildren()
628
+
629
+ return
630
+ }
631
+
632
+ const rows = [
633
+ ['Played', String(stats.count)],
634
+ ['Average', this.saidAsTime(stats.average)],
635
+ ['Median', this.saidAsTime(stats.median)],
636
+ ['Usually', stats.mode === null ? 'no length twice' : this.saidAsTime(stats.mode)],
637
+ ['Without the outliers', stats.trimmed === null ? 'not enough played' : this.saidAsTime(stats.trimmed)],
638
+ ['Shortest', this.saidAsTime(stats.minimum)],
639
+ ['Longest', this.saidAsTime(stats.maximum)],
640
+ ]
641
+
642
+ if (event && event.count > stats.count) {
643
+ rows.push(['Whole event', `${this.saidAsTime(event.median)} over ${event.count}`])
644
+ }
645
+
646
+ box.replaceChildren(...rows.flatMap(([term, said]) => [
647
+ this.textTag('dt', 'durationTerm', term),
648
+ this.textTag('dd', 'durationValue', said),
649
+ ]))
650
+
651
+ box.hidden = false
652
+ }
653
+
654
+ /**
655
+ * How many of a section's blocks open on the first draw. Below the
656
+ * width the host names, none.
657
+ *
658
+ * @param {string} segment
659
+ * @param {boolean} narrow
660
+ * @returns {number}
661
+ */
662
+ opensWith(segment, narrow) {
663
+ return narrow
664
+ ? 0
665
+ : TennisGamedayBoardElement.OPENS_WITH[segment] ?? Infinity
666
+ }
667
+
668
+ /**
669
+ * Whether the day opens with every block shut.
670
+ *
671
+ * A phone shows one block of a day and the rest is scrolling. The
672
+ * board asks the browser rather than guessing from a width it read
673
+ * itself, so it folds on exactly the screens the host's CSS calls
674
+ * small.
675
+ *
676
+ * @returns {boolean}
677
+ */
678
+ startsFolded() {
679
+ const edge = this.getConfig()['fold-below']
680
+ const view = this.ownerDocument?.defaultView
681
+
682
+ if (!edge || typeof view?.matchMedia !== 'function') {
683
+ return false
684
+ }
685
+
686
+ return view.matchMedia(`(max-width: ${edge})`).matches
687
+ }
688
+
689
+ /**
690
+ * @returns {Array<TennisGamedayListElement>}
691
+ */
692
+ lists() {
693
+ return this.$$(TennisGamedayListElement.tag)
694
+ }
695
+
696
+ /**
697
+ * Whether anything is still open, which is what the button offers to
698
+ * do something about.
699
+ *
700
+ * @returns {boolean}
701
+ */
702
+ anyOpen() {
703
+ return this.lists().some(list => list.anyOpen())
704
+ }
705
+
706
+
707
+ /**
708
+ * Shut every block, or open every block.
709
+ *
710
+ * By the afternoon the finished section is most of the day, and
711
+ * shutting it a block at a time is its own chore.
712
+ *
713
+ * @param {boolean} shut
714
+ */
715
+ foldAll(shut) {
716
+ for (const list of this.lists()) {
717
+ list.foldAll(shut)
718
+ }
719
+
720
+ this.rememberFolds()
721
+ this.sayFoldAll()
722
+ }
723
+
724
+
725
+ /**
726
+ * The button says what pressing it will do, not what is true now.
727
+ */
728
+ sayFoldAll() {
729
+ const button = this.$('[data-fold-all]')
730
+
731
+ if (button) {
732
+ button.textContent = this.anyOpen() ? 'Fold all' : 'Unfold all'
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Which blocks are folded shut right now, asked of each list before
738
+ * the board is redrawn.
739
+ */
740
+ rememberFolds() {
741
+ for (const list of this.lists()) {
742
+ list.folded = this.folded
743
+ list.rememberFolds()
744
+ }
745
+ }
746
+
747
+
748
+
749
+ /**
750
+ * @param {object} match
751
+ * @returns {string}
752
+ */
753
+ startTimeOf(match) {
754
+ return facts.startTimeOf(match)
755
+ }
756
+
757
+
758
+ /**
759
+ * One line, drawn by the card that owns it.
760
+ *
761
+ * The board hands a card the match, the day it is in and the shell
762
+ * describing the event, and the card draws itself and says what was
763
+ * pressed. What a line looks like stopped being the board's business
764
+ * here.
765
+ *
766
+ * @param {object} match
767
+ * @returns {HTMLElement}
768
+ */
769
+ matchLine(match) {
770
+ const card = document.createElement(TennisGamedayCardElement.tag)
771
+
772
+ card.match = match
773
+ card.day = this.day
774
+ card.shell = this.shell
775
+
776
+ return card
777
+ }
778
+
779
+
780
+ /**
781
+ * @param {object} match
782
+ * @returns {string}
783
+ */
784
+ lineSummary(match) {
785
+ const card = document.createElement(TennisGamedayCardElement.tag)
786
+
787
+ card.match = match
788
+ card.day = this.day
789
+ card.shell = this.shell
790
+
791
+ return card.summary()
792
+ }
793
+
794
+
795
+ /**
796
+ * @param {object} match
797
+ * @returns {string}
798
+ */
799
+ stateOf(match) {
800
+ return facts.stateOf(match)
801
+ }
802
+
803
+
804
+
805
+ /**
806
+ * @param {object} match
807
+ * @returns {string}
808
+ */
809
+ tookOf(match) {
810
+ return facts.tookOf(match)
811
+ }
812
+
813
+
814
+ /**
815
+ * @param {string|null} iso
816
+ * @returns {string}
817
+ */
818
+ clockOf(iso) {
819
+ return facts.clockOf(iso)
820
+ }
821
+
822
+
823
+ /**
824
+ * @param {object} match
825
+ * @returns {number|null}
826
+ */
827
+ minutesOf(match) {
828
+ return facts.minutesOf(match)
829
+ }
830
+
831
+
832
+ /**
833
+ * @param {number} minutes
834
+ * @returns {string}
835
+ */
836
+ saidAsTime(minutes) {
837
+ return facts.saidAsTime(minutes)
838
+ }
839
+
840
+
841
+ /**
842
+ * @param {HTMLElement} element - anything inside a match line
843
+ * @returns {object|null}
844
+ */
845
+ matchOf(element) {
846
+ const line = element.closest('[data-match-id]')
847
+
848
+ if (!line) {
849
+ return null
850
+ }
851
+
852
+ const id = Number(line.dataset.matchId)
853
+
854
+ return this.day.matches.find(match => match.id === id) ?? null
855
+ }
856
+
857
+ /**
858
+ * Keep what was typed, so a redraw does not lose a half-entered
859
+ * score.
860
+ *
861
+ * @param {{match: object, set: number, side: string, value: string}} typed
862
+ */
863
+ recordScore({ match, set, side, value }) {
864
+ if (!match) {
865
+ return
866
+ }
867
+
868
+ const index = set - 1
869
+
870
+ match.sets[index] = match.sets[index] ?? { set_number: set }
871
+ match.sets[index][`${side}_games`] = value === '' ? null : Number(value)
872
+ }
873
+
874
+ /**
875
+ * @param {object} match
876
+ */
877
+ /**
878
+ * Start a match, or take that start back.
879
+ *
880
+ * The desk works down a list and the button is next to the one above
881
+ * it, so a match gets started by mistake. Taking it back is the same
882
+ * button, the way ending one and reopening it are.
883
+ *
884
+ * @param {object} match
885
+ */
886
+ async toggleStarted(match, onDeck = false) {
887
+ const before = structuredClone(match)
888
+
889
+ if (this.segmentOf(match) === 'started') {
890
+ this.unstartDialog.open(match)
891
+
892
+ return
893
+ }
894
+
895
+ match.started_at = new Date().toISOString()
896
+ match.status = 'live'
897
+
898
+ this.render()
899
+ this.focusScore(match.id)
900
+
901
+ await this.save(match, { action: 'start', on_deck: onDeck }, before)
902
+ }
903
+
904
+ /**
905
+ * End a match, or put a finished one back. Ending one nobody started
906
+ * stamps a start too, since results are reported after the fact.
907
+ *
908
+ * A match cannot be ended on nothing. If no score was typed, the
909
+ * desk is asked what happened instead, because a match that ends
910
+ * without being played out still has a winner and a reason.
911
+ *
912
+ * @param {object} match
913
+ */
914
+ async toggleFinished(match) {
915
+ const before = structuredClone(match)
916
+
917
+ if (this.segmentOf(match) === 'finished') {
918
+ this.reopenDialog.open(match)
919
+
920
+ return
921
+ }
922
+
923
+ if (!this.hasScore(match)) {
924
+ this.forfeitDialog.open(match)
925
+
926
+ return
927
+ }
928
+
929
+ const sets = match.sets.filter(set => this.setHasScore(set))
930
+
931
+ match.started_at = match.started_at ?? new Date().toISOString()
932
+ match.completed_at = new Date().toISOString()
933
+ match.status = 'completed'
934
+
935
+ this.render()
936
+
937
+ // Move focus to the first match still waiting.
938
+ this.focusFirstPending()
939
+
940
+ if (await this.save(match, { action: 'end', sets }, before)) {
941
+ this.nextDialog.open(match)
942
+ }
943
+ }
944
+
945
+ /**
946
+ * A finished match, with its score there to be typed over.
947
+ *
948
+ * Two different things can be wrong with a result. The number can be
949
+ * wrong, which is a correction: the match still happened, still
950
+ * ended when it ended, and only the score moves. Or the match did
951
+ * not happen the way it was reported at all, which takes the whole
952
+ * result back and starts again.
953
+ *
954
+ * @param {object} match
955
+ */
956
+ fillReopen(match) {
957
+ // One row, the way a line reads it: a side, its score, the other
958
+ // score, the other side. The names above the boxes and the boxes
959
+ // below the names are two things to look at; this is one.
960
+ this.$('[data-reopen-match]').replaceChildren(
961
+ ...(match.identifier
962
+ ? [textTag('span', 'modalIdentifier', match.identifier)]
963
+ : []),
964
+ this.teamCell('modalHome', match.home_team_id),
965
+ this.correctionColumn(match, 'home'),
966
+ textTag('span', 'modalVersus', 'vs'),
967
+ this.correctionColumn(match, 'visitor'),
968
+ this.teamCell('modalVisitor', match.visitor_team_id)
969
+ )
970
+
971
+ this.$('[data-reopen-said]').textContent =
972
+ 'The match keeps the time it ended. Re-open it instead if it did not happen this way at all.'
973
+ }
974
+
975
+
976
+ /**
977
+ * One side's score, ready to be typed over.
978
+ *
979
+ * @param {object} match
980
+ * @param {string} side - 'home' or 'visitor'
981
+ * @returns {HTMLElement}
982
+ */
983
+ correctionColumn(match, side) {
984
+ const format = this.formatFor(match)
985
+ const sets = format?.sets ?? [{}]
986
+ const column = document.createElement('span')
987
+
988
+ column.dataset.correctionScores = ''
989
+ column.dataset.side = side
990
+
991
+ sets.forEach((set, index) => {
992
+ column.appendChild(this.scoreField({
993
+ set: index + 1,
994
+ value: match.sets[index]?.[`${side}_games`],
995
+ label: set.label,
996
+ }))
997
+ })
998
+
999
+ return column
1000
+ }
1001
+
1002
+ /**
1003
+ * Fill a <semtic-select> with what it can be.
1004
+ *
1005
+ * The options are its children. semtic reads them and renders the
1006
+ * control around them, whether they were there when it upgraded or
1007
+ * put there afterwards, which is how the board fills these: the
1008
+ * courts and the days only arrive with the shell.
1009
+ *
1010
+ * @param {HTMLElement} field - the <semtic-select> to fill
1011
+ * @param {Array<{value: string, label: string}>} options
1012
+ * @param {string} value
1013
+ */
1014
+ fillSelect(field, options, value) {
1015
+ field.replaceChildren(...options.map(({ value: each, label }) => {
1016
+ const option = document.createElement('option')
1017
+
1018
+ option.value = each
1019
+ option.textContent = label
1020
+ option.selected = each === value
1021
+
1022
+ return option
1023
+ }))
1024
+ }
1025
+
1026
+ /**
1027
+ * @param {string} role - the data attribute the board knows it by
1028
+ * @param {string} position - where its label sits
1029
+ * @returns {HTMLElement}
1030
+ */
1031
+ selectField(role, position = 'top') {
1032
+ const field = document.createElement('semtic-select')
1033
+
1034
+ // Labelled somewhere other than left, which is semtic's default:
1035
+ // left keeps a column for the label whether there is one or not,
1036
+ // and on a board where a select has to be exactly as wide as the
1037
+ // badge over it, that column is the difference.
1038
+ field.setAttribute('label-position', position)
1039
+ field.dataset[role] = ''
1040
+
1041
+ return field
1042
+ }
1043
+
1044
+ /**
1045
+ * @param {{set: number, value: ?number, label: ?string, disabled: ?boolean}} spec
1046
+ * @returns {HTMLElement}
1047
+ */
1048
+ scoreField(spec) {
1049
+ return scoreField(spec)
1050
+ }
1051
+
1052
+
1053
+ /**
1054
+ * What the modal's boxes say, as sets.
1055
+ *
1056
+ * @returns {Array<object>}
1057
+ */
1058
+ correctionSets() {
1059
+ const columns = [...this.$$('[data-correction-scores]')]
1060
+ const sets = []
1061
+
1062
+ for (const column of columns) {
1063
+ for (const input of column.querySelectorAll('input')) {
1064
+ const index = Number(input.dataset.set) - 1
1065
+
1066
+ sets[index] = sets[index] ?? { set_number: index + 1 }
1067
+ sets[index][`${column.dataset.side}_games`] =
1068
+ input.value === '' ? null : Number(input.value)
1069
+ }
1070
+ }
1071
+
1072
+ return sets
1073
+ }
1074
+
1075
+ /**
1076
+ * Only the score moves. The match stays finished and keeps the time
1077
+ * it ended, because it did end then.
1078
+ */
1079
+ async saveCorrection() {
1080
+ const match = this.reopenDialog.match
1081
+
1082
+ if (!match) {
1083
+ return
1084
+ }
1085
+
1086
+ const before = structuredClone(match)
1087
+ const sets = this.correctionSets().filter(set => this.setHasScore(set))
1088
+
1089
+ if (!sets.length) {
1090
+ this.fail('A score has to say who took the match.')
1091
+
1092
+ return
1093
+ }
1094
+
1095
+ this.reopenDialog.close()
1096
+
1097
+ await this.save(match, { action: 'correct', sets }, before)
1098
+ }
1099
+
1100
+ /**
1101
+ * The result did not happen this way at all. What the score becomes
1102
+ * is the server's to settle.
1103
+ */
1104
+ async takeResultBack() {
1105
+ const match = this.reopenDialog.match
1106
+
1107
+ if (!match) {
1108
+ return
1109
+ }
1110
+
1111
+ const before = structuredClone(match)
1112
+
1113
+ match.completed_at = null
1114
+ match.status = match.started_at ? 'live' : 'scheduled'
1115
+
1116
+ this.reopenDialog.close()
1117
+ this.render()
1118
+ this.focusScore(match.id)
1119
+
1120
+ await this.save(match, { action: 'reopen' }, before)
1121
+ }
1122
+
1123
+
1124
+
1125
+ /**
1126
+ * @param {object} match
1127
+ */
1128
+ fillUnstart(match) {
1129
+ this.sayWhichMatch('[data-unstart-match]', match)
1130
+
1131
+ this.$('[data-unstart-said]').textContent = match.started_at
1132
+ ? `Started at ${this.clockOf(match.started_at)}. It goes back to waiting, and that time is forgotten.`
1133
+ : 'It goes back to waiting.'
1134
+ }
1135
+
1136
+
1137
+
1138
+
1139
+ async confirmUnstart() {
1140
+ const match = this.unstartDialog.match
1141
+
1142
+ if (!match) {
1143
+ return
1144
+ }
1145
+
1146
+ const before = structuredClone(match)
1147
+
1148
+ match.started_at = null
1149
+ match.status = 'scheduled'
1150
+
1151
+ this.unstartDialog.close()
1152
+ this.render()
1153
+
1154
+ await this.save(match, { action: 'unstart' }, before)
1155
+ }
1156
+
1157
+ /**
1158
+ * @param {object} set
1159
+ * @returns {boolean}
1160
+ */
1161
+ setHasScore(set) {
1162
+ return facts.setHasScore(set)
1163
+ }
1164
+
1165
+
1166
+ /**
1167
+ * @param {object} match
1168
+ * @returns {boolean}
1169
+ */
1170
+ hasScore(match) {
1171
+ return facts.hasScore(match)
1172
+ }
1173
+
1174
+
1175
+ /**
1176
+ * @returns {string}
1177
+ */
1178
+ forfeitReason() {
1179
+ return this.$('[data-forfeit-reason] input:checked')?.value ?? ''
1180
+ }
1181
+
1182
+ /**
1183
+ * @param {object} match
1184
+ */
1185
+ fillForfeit(match) {
1186
+ this.$('[data-forfeit-match]').textContent = match.identifier ?? ''
1187
+
1188
+ this.$('[data-forfeit-reason]').replaceChildren(
1189
+ ...TennisGamedayBoardElement.REASONS.map(([value, label], index) =>
1190
+ this.choice(`forfeit-reason-${match.id}`, value, label, index === 0, 'right'))
1191
+ )
1192
+
1193
+ // Who won, not who lost. The winner is the one standing at the
1194
+ // desk saying so, and asking an official to name the side that is
1195
+ // not in front of them is a way to get the wrong one clicked.
1196
+ //
1197
+ // Laid out the way a score is: a side, its box, the other box, the
1198
+ // other side. The choice sits where the number would.
1199
+ this.$('[data-forfeit-winner]').replaceChildren(
1200
+ this.choice(`forfeit-winner-${match.id}`, 'home', this.teamName(match.home_team_id), false, 'right'),
1201
+ this.choice(`forfeit-winner-${match.id}`, 'visitor', this.teamName(match.visitor_team_id), false, 'right')
1202
+ )
1203
+
1204
+ this.$('[data-forfeit-note]').value = ''
1205
+ this.$('[data-forfeit-note-field]').hidden = true
1206
+ this.$('[data-forfeit-remaining]').checked = false
1207
+ this.$('[data-forfeit-modal] [semtic-modal-confirm]')?.removeAttribute('disabled')
1208
+ }
1209
+
1210
+
1211
+
1212
+
1213
+
1214
+ /**
1215
+ * @param {string} name
1216
+ * @param {string} value
1217
+ * @param {string} text
1218
+ * @param {boolean} checked
1219
+ * @returns {HTMLElement}
1220
+ */
1221
+ /**
1222
+ * One choice in a group, as <semtic-radio> wants it: a label, a name
1223
+ * shared with its group, a value, and an id of its own. The id is
1224
+ * not optional, because semtic falls back to the group's name when
1225
+ * one is not given and then every radio in the group answers to it.
1226
+ *
1227
+ * @param {string} group
1228
+ * @param {string} value
1229
+ * @param {string} text
1230
+ * @param {boolean} checked
1231
+ * @param {string} position - where the label sits, semtic's default
1232
+ * when not said
1233
+ * @returns {HTMLElement}
1234
+ */
1235
+ choice(group, value, text, checked, position = '') {
1236
+ const radio = document.createElement('semtic-radio')
1237
+
1238
+ radio.setAttribute('label', text)
1239
+
1240
+ if (position) {
1241
+ radio.setAttribute('label-position', position)
1242
+ }
1243
+
1244
+ radio.setAttribute('id', `${group}-${value}`)
1245
+ radio.setAttribute('name', group)
1246
+ radio.setAttribute('value', value)
1247
+
1248
+ if (checked) {
1249
+ radio.setAttribute('checked', '')
1250
+ }
1251
+
1252
+ return radio
1253
+ }
1254
+
1255
+
1256
+
1257
+ /**
1258
+ * Write the forfeit down: a clean set to whoever was left standing,
1259
+ * so it reaches standings as a win rather than as a match nobody
1260
+ * played, and the reason as the status.
1261
+ */
1262
+ async confirmForfeit() {
1263
+ const match = this.forfeitDialog.match
1264
+ const winner = this.$('[data-forfeit-winner] input:checked')?.value
1265
+
1266
+ if (!match || !winner) {
1267
+ return
1268
+ }
1269
+
1270
+ const reason = this.forfeitReason()
1271
+
1272
+ // The desk is asked who won. The server is told who lost, because
1273
+ // that is the side the forfeit is against and the side that may be
1274
+ // out of the rest of it.
1275
+ const loser = winner === 'home' ? 'visitor' : 'home'
1276
+ const note = this.$('[data-forfeit-note]').value.trim()
1277
+ const before = structuredClone(match)
1278
+
1279
+ match.sets = [{
1280
+ set_number: 1,
1281
+ home_games: winner === 'home' ? TennisGamedayBoardElement.FORFEIT_GAMES : 0,
1282
+ visitor_games: winner === 'visitor' ? TennisGamedayBoardElement.FORFEIT_GAMES : 0,
1283
+ }]
1284
+
1285
+ match.started_at = match.started_at ?? new Date().toISOString()
1286
+ match.completed_at = new Date().toISOString()
1287
+ match.status = TennisGamedayBoardElement.REASON_STATUS[reason] ?? 'completed'
1288
+ match.winner_team_id = match[`${winner}_team_id`]
1289
+
1290
+ if (reason === 'other' && note) {
1291
+ match.notes = note
1292
+ }
1293
+
1294
+ const rest = this.$('[data-forfeit-remaining]').checked
1295
+
1296
+ if (rest) {
1297
+ this.forfeitRemaining(match, match[`${loser}_team_id`])
1298
+ }
1299
+
1300
+ this.forfeitDialog.close()
1301
+ this.render()
1302
+ this.focusFirstPending()
1303
+
1304
+ const saved = await this.save(match, {
1305
+ action: 'forfeit',
1306
+ reason,
1307
+ loser_side: loser,
1308
+ note: note || null,
1309
+ forfeit_remaining: rest,
1310
+ }, before)
1311
+
1312
+ // The cascade is the server's to work out, so the day is read
1313
+ // again rather than trusted to the copy this board just guessed at.
1314
+ if (saved && rest) {
1315
+ await this.showDay(this.$('[data-day]').value)
1316
+ }
1317
+
1318
+ // A court is free whether the match was played out or given away.
1319
+ if (saved) {
1320
+ this.nextDialog.open(match)
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Everything else that team was still due to play. To the opponent
1326
+ * on the other side of the net, a team that has left the tournament
1327
+ * is a walkover whatever sent them home.
1328
+ *
1329
+ * @param {object} from
1330
+ * @param {number|null} teamId
1331
+ */
1332
+ forfeitRemaining(from, teamId) {
1333
+ if (teamId == null) {
1334
+ return
1335
+ }
1336
+
1337
+ for (const match of this.day.matches) {
1338
+ if (match.id === from.id || this.segmentOf(match) === 'finished') {
1339
+ continue
1340
+ }
1341
+
1342
+ const side = match.home_team_id === teamId
1343
+ ? 'home'
1344
+ : (match.visitor_team_id === teamId ? 'visitor' : null)
1345
+
1346
+ if (!side) {
1347
+ continue
1348
+ }
1349
+
1350
+ match.sets = [{
1351
+ set_number: 1,
1352
+ home_games: side === 'home' ? 0 : TennisGamedayBoardElement.FORFEIT_GAMES,
1353
+ visitor_games: side === 'visitor' ? 0 : TennisGamedayBoardElement.FORFEIT_GAMES,
1354
+ }]
1355
+
1356
+ match.started_at = match.started_at ?? new Date().toISOString()
1357
+ match.completed_at = new Date().toISOString()
1358
+ match.status = 'walkover'
1359
+ match.winner_team_id = match[side === 'home' ? 'visitor_team_id' : 'home_team_id']
1360
+ }
1361
+ }
1362
+
1363
+ /**
1364
+ * @param {number} matchId
1365
+ */
1366
+ focusScore(matchId) {
1367
+ this.$(`[data-match-id="${matchId}"] [data-side="home"] input`)?.focus(NO_SCROLL)
1368
+ }
1369
+
1370
+ focusFirstPending() {
1371
+ this.$('[data-section="pending"] tennis-gameday-card [data-side="home"] input')?.focus(NO_SCROLL)
1372
+ }
1373
+
1374
+ /**
1375
+ * @param {string} name
1376
+ * @param {string} role
1377
+ * @param {string} text
1378
+ * @returns {HTMLElement}
1379
+ */
1380
+ textTag(name, role, text) {
1381
+ return textTag(name, role, text)
1382
+ }
1383
+
1384
+
1385
+ /**
1386
+ * @param {object} match
1387
+ * @returns {string}
1388
+ */
1389
+ stageOf(match) {
1390
+ return facts.stageOf(this.day, match)
1391
+ }
1392
+
1393
+
1394
+ /**
1395
+ * @param {object} match
1396
+ * @returns {string}
1397
+ */
1398
+ divisionOf(match) {
1399
+ return facts.divisionOf(this.shell, match)
1400
+ }
1401
+
1402
+
1403
+ /**
1404
+ * Which match a dialog is about, drawn the way a line draws it.
1405
+ *
1406
+ * A team's name run together with its players reads as one long
1407
+ * sentence, and a Team Cup tie has eight names in it. The same two
1408
+ * participants the line shows instead, so an official is looking at
1409
+ * what they were already looking at. Singles and doubles come out of
1410
+ * the same call: one name, or two, or a name with a squad under it.
1411
+ *
1412
+ * @param {string} selector
1413
+ * @param {object} match
1414
+ */
1415
+ sayWhichMatch(selector, match) {
1416
+ const said = this.$(selector)
1417
+
1418
+ said.replaceChildren(
1419
+ ...(match.identifier
1420
+ ? [this.textTag('span', 'modalIdentifier', match.identifier)]
1421
+ : []),
1422
+ this.teamCell('modalHome', match.home_team_id),
1423
+ this.textTag('span', 'modalVersus', 'vs'),
1424
+ this.teamCell('modalVisitor', match.visitor_team_id)
1425
+ )
1426
+ }
1427
+
1428
+ /**
1429
+ * @param {string} role - 'home' or 'visitor'
1430
+ * @param {number|null} teamId
1431
+ * @returns {HTMLElement}
1432
+ */
1433
+ teamCell(role, teamId) {
1434
+ return teamCell(this.day, role, teamId)
1435
+ }
1436
+
1437
+
1438
+ /**
1439
+ * @param {number|null} teamId
1440
+ * @returns {string}
1441
+ */
1442
+ teamName(teamId) {
1443
+ return facts.teamName(this.day, teamId)
1444
+ }
1445
+
1446
+
1447
+
1448
+ /**
1449
+ * @param {object} match
1450
+ * @returns {object|null}
1451
+ */
1452
+ formatFor(match) {
1453
+ return facts.formatFor(this.shell, match)
1454
+ }
1455
+
1456
+
1457
+ /**
1458
+ * @param {object} match
1459
+ * @returns {number|null}
1460
+ */
1461
+ effectiveCourtId(match) {
1462
+ return facts.effectiveCourtId(match)
1463
+ }
1464
+
1465
+
1466
+ /**
1467
+ * @param {object} match
1468
+ * @returns {boolean}
1469
+ */
1470
+ movedCourt(match) {
1471
+ return facts.movedCourt(match)
1472
+ }
1473
+
1474
+
1475
+ /**
1476
+ * @param {object|null} match
1477
+ * @returns {Array<{value: string, label: string}>}
1478
+ */
1479
+ courtOptions(match) {
1480
+ return facts.courtOptions(this.shell, this.day, match)
1481
+ }
1482
+
1483
+
1484
+ /**
1485
+ * @param {object|null} match
1486
+ * @returns {Array<{value: string, label: string}>}
1487
+ */
1488
+ freeCourtOptions(match) {
1489
+ return facts.freeCourtOptions(this.shell, this.day, match)
1490
+ }
1491
+
1492
+
1493
+ /**
1494
+ * @param {number|null} courtId
1495
+ * @returns {string}
1496
+ */
1497
+ courtName(courtId) {
1498
+ return facts.courtName(this.shell, courtId)
1499
+ }
1500
+
1501
+
1502
+
1503
+ /**
1504
+ * What happens on the court the match was just played on.
1505
+ *
1506
+ * The modal is handed the match that finished and works the rest out
1507
+ * itself: which court that was, what is still queued on it, and
1508
+ * whether the wait is worth saying out loud.
1509
+ *
1510
+ * @param {object} match
1511
+ */
1512
+ fillNext(match) {
1513
+ const said = whatIsNext(this.day, match)
1514
+
1515
+ const court = this.courtName(this.effectiveCourtId(match))
1516
+ const wait = facts.saidAsTime(said.minutes)
1517
+
1518
+ const headers = {
1519
+ behind: `${court} is running behind`,
1520
+ idle: `${court} is free for ${wait}`,
1521
+ next: `Next on ${court}`,
1522
+ clear: `${court} is done for the day`,
1523
+ }
1524
+
1525
+ const sentences = {
1526
+ behind: `It should have started ${wait} ago.`,
1527
+ idle: said.typical === null
1528
+ ? ''
1529
+ : `A match here takes about ${facts.saidAsTime(Math.round(said.typical))}, so something could be pulled forward.`,
1530
+ next: '',
1531
+ clear: 'Nothing else is scheduled on it.',
1532
+ }
1533
+
1534
+ this.$('[data-next-modal]').setAttribute('header', headers[said.kind])
1535
+ this.$('[data-next-said]').textContent = sentences[said.kind]
1536
+ this.$('[data-next-said]').hidden = !sentences[said.kind]
1537
+
1538
+ // Two blocks: what has been standing around longest anywhere, and
1539
+ // what this court is holding. The court that just came free is
1540
+ // somewhere to put the first of those, which is the whole point of
1541
+ // saying it here.
1542
+ const longest = said.longest
1543
+
1544
+ this.sayMatchBlock('waiting', longest, 'Longest waiting match')
1545
+
1546
+ // One match, said once. When the longest wait is this court's own
1547
+ // queue, a second block would be the same two names again.
1548
+ this.sayMatchBlock(
1549
+ 'queued',
1550
+ said.next && said.next !== longest ? said.next : null,
1551
+ `Next planned match on ${court}`
1552
+ )
1553
+
1554
+ // Nothing is waiting anywhere, so there is nothing to put on.
1555
+ this.$('[data-next-modal] [semtic-modal-confirm]')
1556
+ ?.toggleAttribute('disabled', !longest && !said.next)
1557
+ }
1558
+
1559
+ /**
1560
+ * Put the picked match on the court that just came free.
1561
+ *
1562
+ * Two writes, because they are two things: the match moves court,
1563
+ * and then it starts. A match already planned for this court only
1564
+ * does the second. Either is a thing that happened and the logbook
1565
+ * says so separately.
1566
+ *
1567
+ * @param {number|null} courtId - the court that came free
1568
+ */
1569
+ async playPicked(courtId) {
1570
+ const picked = this.$('[data-next-modal] input[name="next-play"]:checked')?.value
1571
+ const match = this.day.matches.find(other => String(other.id) === picked)
1572
+
1573
+ this.nextDialog.close()
1574
+
1575
+ if (!match || courtId === null || courtId === undefined) {
1576
+ return
1577
+ }
1578
+
1579
+ if (this.effectiveCourtId(match) !== courtId) {
1580
+ await this.moveCourt(match, courtId)
1581
+ }
1582
+
1583
+ // Said out loud, because a match picked off the queue is one the
1584
+ // players are not standing beside. Working down the board is the
1585
+ // other case and says nothing.
1586
+ await this.toggleStarted(match, true)
1587
+ }
1588
+
1589
+ /**
1590
+ * One match in the next-up dialog: where and when it was meant to
1591
+ * go on, what it is, and who is in it.
1592
+ *
1593
+ * [Court 3] 09:15 Beginner Group A
1594
+ * Alice, Bob vs Carl, Dana
1595
+ *
1596
+ * The planned time, not the time anything actually happened. A day
1597
+ * running behind is the gap between those two, and an official
1598
+ * reading a plan needs the plan.
1599
+ *
1600
+ * @param {string} role - 'waiting' or 'queued'
1601
+ * @param {object|null} match
1602
+ * @param {string} label
1603
+ */
1604
+ sayMatchBlock(role, match, label) {
1605
+ for (const part of ['label', 'head', 'match']) {
1606
+ this.$(`[data-${role}-${part}]`).hidden = !match
1607
+ }
1608
+
1609
+ if (!match) {
1610
+ this.$(`[data-${role}-head]`).replaceChildren()
1611
+ this.$(`[data-${role}-match]`).replaceChildren()
1612
+
1613
+ return
1614
+ }
1615
+
1616
+ this.$(`[data-${role}-label]`).replaceChildren(this.choice(
1617
+ 'next-play',
1618
+ String(match.id),
1619
+ label,
1620
+ role === 'waiting',
1621
+ 'right'
1622
+ ))
1623
+
1624
+ this.$(`[data-${role}-head]`).replaceChildren(
1625
+ textTag('span', 'pickCourt', this.courtName(this.effectiveCourtId(match))),
1626
+ textTag('span', 'pickWhen', this.startTimeOf(match)),
1627
+ textTag('span', 'pickWhat', [
1628
+ this.divisionOf(match),
1629
+ this.stageOf(match),
1630
+ ].filter(Boolean).join(' '))
1631
+ )
1632
+
1633
+ this.sayWhichMatch(`[data-${role}-match]`, match)
1634
+ }
1635
+
1636
+ /**
1637
+ * @param {object} match
1638
+ */
1639
+ fillCourt(match) {
1640
+ this.sayWhichMatch('[data-court-modal-match]', match)
1641
+
1642
+ this.$('[data-court-modal-planned]').textContent =
1643
+ `Planned for ${this.courtName(match.court_id)}.`
1644
+
1645
+ this.$('[data-court-reset]').hidden = !this.movedCourt(match)
1646
+
1647
+ this.fillSelect(
1648
+ this.$('[data-court-modal-select]'),
1649
+ this.courtOptions(match),
1650
+ String(this.effectiveCourtId(match) ?? '')
1651
+ )
1652
+ }
1653
+
1654
+
1655
+
1656
+
1657
+ /**
1658
+ * @param {object|null} match
1659
+ * @param {number} courtId
1660
+ */
1661
+ async moveCourt(match, courtId) {
1662
+ if (!match || Number.isNaN(courtId)) {
1663
+ return
1664
+ }
1665
+
1666
+ const before = structuredClone(match)
1667
+
1668
+ match.actual_court_id = courtId === match.court_id ? null : courtId
1669
+
1670
+ this.render()
1671
+
1672
+ await this.save(match, { action: 'court', actual_court_id: match.actual_court_id }, before)
1673
+ }
1674
+
1675
+ /**
1676
+ * @param {object|null} match
1677
+ */
1678
+ async resetCourt(match) {
1679
+ if (!match) {
1680
+ return
1681
+ }
1682
+
1683
+ const before = structuredClone(match)
1684
+
1685
+ match.actual_court_id = null
1686
+
1687
+ this.render()
1688
+
1689
+ await this.save(match, { action: 'court', actual_court_id: null }, before)
1690
+ }
1691
+ }