@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.
- package/Changes +26 -0
- package/lib/auto-planner.mjs +5 -32
- package/lib/components/day-grid.mjs +288 -0
- package/lib/components/gameday-board.mjs +1691 -0
- package/lib/components/gameday-card.mjs +325 -0
- package/lib/components/gameday-list.mjs +199 -0
- package/lib/components/match.mjs +368 -0
- package/lib/components/participant.mjs +101 -0
- package/lib/components/planning-board.mjs +914 -131
- package/lib/components/schedule-board.mjs +219 -50
- package/lib/day-rows.mjs +19 -3
- package/lib/gameday-dialog.mjs +93 -0
- package/lib/gameday-facts.mjs +305 -0
- package/lib/gameday-next.mjs +164 -0
- package/lib/gameday-parts.mjs +112 -0
- package/lib/index.mjs +1 -3
- package/lib/knockout-draw.mjs +4 -0
- package/lib/knockout-planner.mjs +99 -5
- package/lib/lighting.mjs +50 -4
- package/lib/plan-placement.mjs +54 -3
- package/lib/planning-day.mjs +15 -9
- package/lib/webcomponents.mjs +12 -0
- package/package.json +6 -1
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
|
@@ -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
|
+
}
|