@opndev/tennis-match-center 0.0.1 → 0.0.3

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