@opndev/tennis-match-center 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Changes +5 -0
- package/LICENSE +235 -0
- package/README.md +218 -0
- package/lib/auto-planner.mjs +1297 -0
- package/lib/bracket-autofill.mjs +174 -0
- package/lib/bracket-single-elimination.mjs +496 -0
- package/lib/busy-button.mjs +58 -0
- package/lib/components/bracket-board.mjs +586 -0
- package/lib/components/planning-board.mjs +1179 -0
- package/lib/components/roster-board.mjs +273 -0
- package/lib/components/roster-team.mjs +92 -0
- package/lib/components/schedule-board.mjs +1193 -0
- package/lib/court-bands.mjs +185 -0
- package/lib/court-runs.mjs +76 -0
- package/lib/day-rows.mjs +116 -0
- package/lib/division-hues.mjs +52 -0
- package/lib/five-team-planner.mjs +21 -0
- package/lib/flags.mjs +60 -0
- package/lib/four-team-planner.mjs +216 -0
- package/lib/group-schedule.mjs +175 -0
- package/lib/index.mjs +9 -0
- package/lib/knockout-draw.mjs +209 -0
- package/lib/knockout-planner.mjs +573 -0
- package/lib/knockout-waves.mjs +156 -0
- package/lib/lighting.mjs +50 -0
- package/lib/match-time.mjs +13 -0
- package/lib/occupancy.mjs +74 -0
- package/lib/paired-court-planner.mjs +236 -0
- package/lib/plan-placement.mjs +219 -0
- package/lib/plan.mjs +133 -0
- package/lib/planning-day.mjs +177 -0
- package/lib/roster-seeding.mjs +65 -0
- package/lib/run-queue-planner.mjs +603 -0
- package/lib/sessions.mjs +48 -0
- package/lib/three-team-planner.mjs +122 -0
- package/lib/webcomponents.mjs +25 -0
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
- package/package.json +39 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { HTMLElementSugar } from '@skirbi/sugar'
|
|
6
|
+
import { withLitRequired } from '../lighting.mjs'
|
|
7
|
+
import { autoFillPlanning, activeSession } from '../auto-planner.mjs'
|
|
8
|
+
import { rowsForDate } from '../day-rows.mjs'
|
|
9
|
+
import { withBusyButton } from '../busy-button.mjs'
|
|
10
|
+
import { competitionHue, divisionHues } from '../division-hues.mjs'
|
|
11
|
+
|
|
12
|
+
const SAVING = { busy: 'Saving…', done: 'Saved', failed: 'Save failed' }
|
|
13
|
+
|
|
14
|
+
// A row's fallback length when a booked block's slot no longer matches
|
|
15
|
+
// any configured session (spec.md never promises one) - just enough to
|
|
16
|
+
// give it a real height on the timeline rather than none at all.
|
|
17
|
+
const FALLBACK_SLOT_MINUTES = 45
|
|
18
|
+
|
|
19
|
+
// Pixels per minute of real time on the timeline. A 45-minute slot -
|
|
20
|
+
// the shortest common match length - needs room for four lines
|
|
21
|
+
// (competition, division, group, round) plus padding, not just the
|
|
22
|
+
// one or two a bare label used to need.
|
|
23
|
+
const PX_PER_MINUTE = 64 / 45
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* <tennis-planning-board api="..." save-api="...">
|
|
27
|
+
*
|
|
28
|
+
* The Scheduling grid - "Group Stage Scheduling" / "Time Slots" in
|
|
29
|
+
* spec.md: one tab per day, one grid per day, drawn to scale like a
|
|
30
|
+
* Google Calendar day view - courts are columns, and each slot is
|
|
31
|
+
* positioned/sized from its own real start/end minutes rather than a
|
|
32
|
+
* uniform row, so competitions on different clocks (45-minute matches
|
|
33
|
+
* from 08:00 next to 90-minute ones from 09:00) still line up by
|
|
34
|
+
* actual time (see rowsFor/dayRangeFor). A court can only ever host
|
|
35
|
+
* one match in a given hour, group or knockout, so there is exactly
|
|
36
|
+
* one grid to avoid double-booking across phases. Excel-style range
|
|
37
|
+
* selection:
|
|
38
|
+
* click-drag across a block of cells, then click a competition in the
|
|
39
|
+
* legend to assign that whole block at once. A competition's
|
|
40
|
+
* divisions share whatever capacity it's given; we don't schedule at
|
|
41
|
+
* division granularity. Only ever mutates the in-browser copy until
|
|
42
|
+
* Save is pressed.
|
|
43
|
+
*
|
|
44
|
+
* Which competitions are offered in a given day's legend is driven by
|
|
45
|
+
* the API's per-competition group_dates/knockout_dates (computed
|
|
46
|
+
* server-side from the competition's start_date/end_date - see
|
|
47
|
+
* MatchDayPlanningController): a competition only shows up on the
|
|
48
|
+
* days it's actually relevant to, and the legend already knows which
|
|
49
|
+
* phase (group or knockout) applies on that day, so a click just
|
|
50
|
+
* assigns the current selection to that competition+phase.
|
|
51
|
+
*
|
|
52
|
+
* Each competition's games_needed (from the API, assuming full
|
|
53
|
+
* groups), split by phase, is shown next to the phase that's active
|
|
54
|
+
* that day so the director can judge how much capacity a competition
|
|
55
|
+
* still needs, without this component knowing anything about how
|
|
56
|
+
* that number is computed.
|
|
57
|
+
*
|
|
58
|
+
* "Auto-fill from sessions" (auto-planner.mjs) replaces the whole
|
|
59
|
+
* grid with an automated allocation driven by each competition's
|
|
60
|
+
* configured CompetitionSession rows, instead of a director
|
|
61
|
+
* click-dragging every cell by hand - the manual tools above still
|
|
62
|
+
* work afterward to touch up the result.
|
|
63
|
+
*
|
|
64
|
+
* Day tabs are built as a <semtic-tabbed> subtree entirely in memory
|
|
65
|
+
* and inserted in one shot: that component only ever looks at its
|
|
66
|
+
* <semtic-tab> children once, in its own connectedCallback, so they
|
|
67
|
+
* all have to already exist by the time it's connected - there's no
|
|
68
|
+
* "add another day later" once it's live.
|
|
69
|
+
*/
|
|
70
|
+
export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
71
|
+
static tag = 'tennis-planning-board'
|
|
72
|
+
static renderGuardSelector = '[data-schedule]'
|
|
73
|
+
|
|
74
|
+
static attributeDefs = {
|
|
75
|
+
'shell-api': {},
|
|
76
|
+
'competitions-api': {},
|
|
77
|
+
'save-api': {},
|
|
78
|
+
'csrf-token': {},
|
|
79
|
+
'dark-from': {},
|
|
80
|
+
|
|
81
|
+
// The last hour anything may start. A knockout draw that cannot
|
|
82
|
+
// finish under it is laid out again with its rounds broken into
|
|
83
|
+
// equal parts rather than allowed to run into the small hours or
|
|
84
|
+
// spill onto the next morning - see auto-planner.mjs's own retry.
|
|
85
|
+
'day-end': {},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static HtmlTemplate = ['tennis-planning-board-template', function () {
|
|
89
|
+
return this.tpl(`
|
|
90
|
+
<div>
|
|
91
|
+
<p data-status>Loading…</p>
|
|
92
|
+
<button data-auto-fill type="button" hidden>Auto-fill from sessions</button>
|
|
93
|
+
<label data-registration hidden>
|
|
94
|
+
<input data-registration-closed type="checkbox">
|
|
95
|
+
Registration closed
|
|
96
|
+
</label>
|
|
97
|
+
<label data-always-bye hidden>
|
|
98
|
+
<input data-always-bye-short-groups type="checkbox">
|
|
99
|
+
Always give a short group its bye
|
|
100
|
+
</label>
|
|
101
|
+
<p data-short-groups hidden></p>
|
|
102
|
+
<p data-auto-fill-shortfall hidden></p>
|
|
103
|
+
<p data-auto-fill-warnings hidden></p>
|
|
104
|
+
<button data-clear-all type="button" hidden>Clear planning (all)</button>
|
|
105
|
+
<button data-clear-day type="button" hidden>Clear planning (day)</button>
|
|
106
|
+
<label data-view-toggle hidden>
|
|
107
|
+
<input data-show-data type="checkbox">
|
|
108
|
+
Show data
|
|
109
|
+
</label>
|
|
110
|
+
<pre data-plan hidden></pre>
|
|
111
|
+
<div data-schedule hidden></div>
|
|
112
|
+
<button data-save type="button" hidden>Save planning</button>
|
|
113
|
+
</div>
|
|
114
|
+
`)
|
|
115
|
+
}]
|
|
116
|
+
|
|
117
|
+
async connectedCallback() {
|
|
118
|
+
super.connectedCallback()
|
|
119
|
+
|
|
120
|
+
// Guard the one-time DOM setup, but always (re)fetch and render
|
|
121
|
+
// below. A morph-driven insertion can disconnect/reconnect this
|
|
122
|
+
// element once synchronously right after creation; if fetch were
|
|
123
|
+
// also skipped on the second connect, whichever instance ends up
|
|
124
|
+
// permanently live would never get real data.
|
|
125
|
+
if (!this.hasRenderedShape()) {
|
|
126
|
+
this.appendChild(this.renderFromTemplate())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!this._listenersBound) {
|
|
130
|
+
this._listenersBound = true
|
|
131
|
+
|
|
132
|
+
this.$('[data-save]').addEventListener('click', () => this.save())
|
|
133
|
+
this.$('[data-auto-fill]').addEventListener('click', () => this.autoFill())
|
|
134
|
+
// Config for whatever Auto-fill computes next, not a trigger of
|
|
135
|
+
// their own - a director flips one of these deliberately, then
|
|
136
|
+
// presses Auto-fill when ready, not the moment they change their
|
|
137
|
+
// mind about it. render() still reflects the new choice in the
|
|
138
|
+
// short-groups status line immediately, without recomputing or
|
|
139
|
+
// replacing the grid.
|
|
140
|
+
this.$('[data-registration-closed]').addEventListener('change', () => this.render())
|
|
141
|
+
this.$('[data-always-bye-short-groups]').addEventListener('change', () => this.render())
|
|
142
|
+
this.$('[data-show-data]').addEventListener('change', () => this.render())
|
|
143
|
+
this.$('[data-clear-all]').addEventListener('click', () => this.clearAll())
|
|
144
|
+
this.$('[data-clear-day]').addEventListener('click', () => {
|
|
145
|
+
const activeDate = this.$('[data-schedule] semtic-tabbed')?.getAttribute('current')
|
|
146
|
+
this.clearDay(activeDate)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Bound on window, not this element, so releasing the mouse
|
|
150
|
+
// outside the grid (a very normal thing to do mid-drag) still
|
|
151
|
+
// ends the selection instead of leaving it stuck "selecting".
|
|
152
|
+
window.addEventListener('mouseup', () => {
|
|
153
|
+
this.isSelecting = false
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Backspace/Delete clears the current selection - the same
|
|
157
|
+
// action as clicking "Clear selection" in the legend, just
|
|
158
|
+
// faster for a director working through a lot of cells.
|
|
159
|
+
window.addEventListener('keydown', event => {
|
|
160
|
+
if (event.key !== 'Backspace' && event.key !== 'Delete') {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!this.selectionDate || this.selection.size === 0) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
event.preventDefault()
|
|
169
|
+
this.assignSelection(null)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The shell (courts/days/slots/the already-saved plan) is plain
|
|
174
|
+
// queries - fast regardless of event size - so it renders the
|
|
175
|
+
// real grid immediately. competitions-api is the expensive part
|
|
176
|
+
// (GameCounter runs per division) and is never let hold up that
|
|
177
|
+
// first paint - on a slow connection especially, a grid that
|
|
178
|
+
// shows up while the legend is still catching up reads as "busy",
|
|
179
|
+
// not "broken", the way one long blank "Loading..." does.
|
|
180
|
+
const shellApplied = await this.fetchShell()
|
|
181
|
+
|
|
182
|
+
if (!shellApplied) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.blocks = this.state.blocks.map(block => ({ ...block }))
|
|
187
|
+
this.selection = new Set()
|
|
188
|
+
this.selectionDate = null
|
|
189
|
+
this.isSelecting = false
|
|
190
|
+
|
|
191
|
+
this.render()
|
|
192
|
+
|
|
193
|
+
await this.fetchCompetitions()
|
|
194
|
+
this.render()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @returns {Promise<boolean>} false if a newer fetch has since
|
|
199
|
+
* started and this one's result must be discarded.
|
|
200
|
+
*/
|
|
201
|
+
async fetchShell() {
|
|
202
|
+
const data = await this.fetchJSON(this.getConfig()['shell-api'])
|
|
203
|
+
|
|
204
|
+
if (!data) {
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.state = withLitRequired({ ...data, competitions: [] }, this.getConfig()['dark-from'])
|
|
209
|
+
this._divisionHues = null
|
|
210
|
+
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Re-fetches competitions-api and replaces this.state.competitions -
|
|
216
|
+
* the competitions/groups config (pairing, pool_size, sessions, ...)
|
|
217
|
+
* a director just changed on another page (Competitions), not only
|
|
218
|
+
* whatever was current on the last fetch. Without this, "Auto-fill"
|
|
219
|
+
* kept computing against whatever config was current when Planning
|
|
220
|
+
* was last opened (or last auto-filled), and picking up a changed
|
|
221
|
+
* pairing meant reloading the whole page. Only this, the expensive
|
|
222
|
+
* half, needs re-fetching for that - the grid shell it's layered
|
|
223
|
+
* onto (courts, days, slots) does not change from a pairing edit.
|
|
224
|
+
*
|
|
225
|
+
* @returns {Promise<boolean>} false if a newer fetch has since
|
|
226
|
+
* started and this one's result must be discarded.
|
|
227
|
+
*/
|
|
228
|
+
async fetchCompetitions() {
|
|
229
|
+
const data = await this.fetchJSON(this.getConfig()['competitions-api'])
|
|
230
|
+
|
|
231
|
+
if (!data) {
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.state.competitions = data.competitions
|
|
236
|
+
this._divisionHues = null
|
|
237
|
+
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Shared double-connect/stale-request guard: only the result of the
|
|
243
|
+
* most recently started fetch (shell or competitions, whichever)
|
|
244
|
+
* is ever allowed to apply - an earlier one resolving after a newer
|
|
245
|
+
* fetch has already started returns null instead.
|
|
246
|
+
*/
|
|
247
|
+
async fetchJSON(url) {
|
|
248
|
+
const requestId = (this._fetchGeneration = (this._fetchGeneration ?? 0) + 1)
|
|
249
|
+
|
|
250
|
+
const response = await fetch(url, {
|
|
251
|
+
headers: { Accept: 'application/json' },
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const data = await response.json()
|
|
255
|
+
|
|
256
|
+
return requestId === this._fetchGeneration ? data : null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Competitions relevant to one day, each tagged with the phase that
|
|
261
|
+
* applies on that day - group_dates and knockout_dates for the same
|
|
262
|
+
* competition never overlap, so at most one entry per competition.
|
|
263
|
+
*/
|
|
264
|
+
competitionsForDate(date) {
|
|
265
|
+
const entries = []
|
|
266
|
+
|
|
267
|
+
for (const competition of this.state.competitions) {
|
|
268
|
+
if (competition.group_dates.includes(date)) {
|
|
269
|
+
entries.push({ competition, phase: 'group' })
|
|
270
|
+
}
|
|
271
|
+
if (competition.knockout_dates.includes(date)) {
|
|
272
|
+
entries.push({ competition, phase: 'knockout' })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return entries
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
blockAt(courtId, date, slot) {
|
|
280
|
+
return this.blocks.find(block =>
|
|
281
|
+
block.court_id === courtId && block.date === date && block.slot === slot
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
isSelectable(court, slot) {
|
|
286
|
+
// A physical impossibility, not a soft preference - spec.md: "a
|
|
287
|
+
// non-lit court cannot be assigned to the evening slot".
|
|
288
|
+
return !(slot.lit_required && !court.lit)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
cellKey(row, col) {
|
|
292
|
+
return `${row},${col}`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
startSelection(date, row, col) {
|
|
296
|
+
// A fresh day's grid always starts a fresh selection - dragging
|
|
297
|
+
// never spans two days, since they're on different tabs and can't
|
|
298
|
+
// both be visible to drag across in the first place.
|
|
299
|
+
if (date !== this.selectionDate) {
|
|
300
|
+
this.selection = new Set()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.selectionDate = date
|
|
304
|
+
this.isSelecting = true
|
|
305
|
+
this.selectionAnchor = { row, col }
|
|
306
|
+
this.updateSelection(row, col)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
updateSelection(row, col) {
|
|
310
|
+
if (!this.selectionAnchor) {
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const rowMin = Math.min(this.selectionAnchor.row, row)
|
|
315
|
+
const rowMax = Math.max(this.selectionAnchor.row, row)
|
|
316
|
+
const colMin = Math.min(this.selectionAnchor.col, col)
|
|
317
|
+
const colMax = Math.max(this.selectionAnchor.col, col)
|
|
318
|
+
|
|
319
|
+
const selection = new Set()
|
|
320
|
+
|
|
321
|
+
// Rows follow what is actually drawn for the day, not the
|
|
322
|
+
// standing slot list - they are no longer the same thing.
|
|
323
|
+
const rows = this.rowsFor(this.selectionDate)
|
|
324
|
+
|
|
325
|
+
for (let r = rowMin; r <= rowMax; r++) {
|
|
326
|
+
for (let c = colMin; c <= colMax; c++) {
|
|
327
|
+
const court = this.state.courts[c]
|
|
328
|
+
const slot = rows[r]
|
|
329
|
+
|
|
330
|
+
if (this.isSelectable(court, slot)) {
|
|
331
|
+
selection.add(this.cellKey(r, c))
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
this.selection = selection
|
|
337
|
+
this.paintSelection()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Toggles the visual selected state directly, without rebuilding the
|
|
342
|
+
* grid, so a drag-select stays smooth even on a large tournament's
|
|
343
|
+
* worth of cells. Scoped to the active day only.
|
|
344
|
+
*/
|
|
345
|
+
paintSelection() {
|
|
346
|
+
const day = this.$(`planning-day[data-day="${this.selectionDate}"]`)
|
|
347
|
+
|
|
348
|
+
if (!day) {
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const slot of day.querySelectorAll('planning-slot[data-row]')) {
|
|
353
|
+
const key = this.cellKey(Number(slot.dataset.row), Number(slot.dataset.col))
|
|
354
|
+
slot.toggleAttribute('data-selected', this.selection.has(key))
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Assigns (or, with competitionId null, clears) every cell in the
|
|
360
|
+
* current selection at once - the actual "Excel blob" action. The
|
|
361
|
+
* phase comes from whichever legend entry was clicked (a
|
|
362
|
+
* competition can only have one applicable phase on a given day).
|
|
363
|
+
*/
|
|
364
|
+
assignSelection(competitionId, phase) {
|
|
365
|
+
if (!this.selectionDate || this.selection.size === 0) {
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const rows = this.rowsFor(this.selectionDate)
|
|
370
|
+
|
|
371
|
+
for (const key of this.selection) {
|
|
372
|
+
const [row, col] = key.split(',').map(Number)
|
|
373
|
+
const court = this.state.courts[col]
|
|
374
|
+
const slot = rows[row]
|
|
375
|
+
|
|
376
|
+
this.blocks = this.blocks.filter(block =>
|
|
377
|
+
!(block.court_id === court.id && block.date === this.selectionDate && block.slot === slot.key)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if (competitionId) {
|
|
381
|
+
this.blocks.push({
|
|
382
|
+
id: null,
|
|
383
|
+
court_id: court.id,
|
|
384
|
+
date: this.selectionDate,
|
|
385
|
+
slot: slot.key,
|
|
386
|
+
phase,
|
|
387
|
+
competition_id: competitionId,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
393
|
+
bubbles: true,
|
|
394
|
+
detail: { blocks: this.blocks },
|
|
395
|
+
}))
|
|
396
|
+
|
|
397
|
+
this.selection = new Set()
|
|
398
|
+
this.render()
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* How many one-hour matches are currently scheduled for a
|
|
403
|
+
* competition in the given phase, so the legend can show
|
|
404
|
+
* "scheduled X" next to its games_needed for that phase.
|
|
405
|
+
*/
|
|
406
|
+
scheduledGames(competitionId, phase) {
|
|
407
|
+
return this.blocks.filter(block => block.competition_id === competitionId && block.phase === phase).length
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Replaces the whole grid with an automated allocation driven by
|
|
412
|
+
* each competition's configured sessions (see auto-planner.mjs) -
|
|
413
|
+
* only ever mutates the in-browser copy, same as a manual
|
|
414
|
+
* assignment; Save still has to be pressed to persist it. A
|
|
415
|
+
* competition with no sessions configured is silently skipped by
|
|
416
|
+
* the algorithm (nothing to say when it should start), not reported
|
|
417
|
+
* as a shortfall.
|
|
418
|
+
*/
|
|
419
|
+
/**
|
|
420
|
+
* Whether the rosters are final.
|
|
421
|
+
*
|
|
422
|
+
* While registration is open a group that looks short may still fill
|
|
423
|
+
* from the waitlist, so planning holds a full run for it. Once it is
|
|
424
|
+
* closed the short groups are real, and they are pooled onto shared
|
|
425
|
+
* court pairs instead.
|
|
426
|
+
*/
|
|
427
|
+
registrationClosed() {
|
|
428
|
+
return this.$('[data-registration-closed]')?.checked ?? false
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A director's explicit departure from that default: keep giving a
|
|
433
|
+
* short group its bye - held as a full run of its division's
|
|
434
|
+
* configured size - even once registration is closed, rather than
|
|
435
|
+
* ever pooling it with other short groups.
|
|
436
|
+
*/
|
|
437
|
+
alwaysByeShortGroups() {
|
|
438
|
+
return this.$('[data-always-bye-short-groups]')?.checked ?? false
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The groups that will be pooled rather than given a court of their
|
|
443
|
+
* own. Worth saying out loud: they are the ones a director can still
|
|
444
|
+
* fill from the waitlist.
|
|
445
|
+
*/
|
|
446
|
+
shortGroups() {
|
|
447
|
+
return this.state.competitions.flatMap(competition =>
|
|
448
|
+
(competition.groups ?? []).filter(
|
|
449
|
+
group => (group.team_count ?? 0) === 4 && group.pool_size === 5
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Re-fetches the competitions/groups config before computing, so a
|
|
456
|
+
* pairing (or any other config) changed on the Competitions page
|
|
457
|
+
* just before clicking this is what actually gets planned against -
|
|
458
|
+
* not whatever was current when this page happened to load.
|
|
459
|
+
*/
|
|
460
|
+
async autoFill() {
|
|
461
|
+
// fetchCompetitions()'s own round trip plus the fill itself is
|
|
462
|
+
// real, visible time on a plan this size - a click that changes
|
|
463
|
+
// nothing on screen until it's done reads as broken, not busy.
|
|
464
|
+
const button = this.$('[data-auto-fill]')
|
|
465
|
+
const label = button.textContent
|
|
466
|
+
button.disabled = true
|
|
467
|
+
button.textContent = 'Auto-filling…'
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const applied = await this.fetchCompetitions()
|
|
471
|
+
|
|
472
|
+
if (!applied) {
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const { blocks, shortfall, warnings } = autoFillPlanning({
|
|
477
|
+
courts: this.state.courts,
|
|
478
|
+
slots: this.state.slots,
|
|
479
|
+
days: this.state.days,
|
|
480
|
+
competitions: this.state.competitions,
|
|
481
|
+
registrationClosed: this.registrationClosed(),
|
|
482
|
+
alwaysByeShortGroups: this.alwaysByeShortGroups(),
|
|
483
|
+
courtOverrides: this.state.court_overrides ?? [],
|
|
484
|
+
|
|
485
|
+
// Group play follows each competition's own session, so a
|
|
486
|
+
// competition starting at 09:00 starts at 09:00. The list is
|
|
487
|
+
// still passed because the knockout pass has not been moved
|
|
488
|
+
// over yet and would otherwise have no slots at all.
|
|
489
|
+
groupSlotsFromSessions: true,
|
|
490
|
+
darkFrom: this.getConfig()['dark-from'],
|
|
491
|
+
dayEnd: this.getConfig()['day-end'] || undefined,
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
this.blocks = blocks
|
|
495
|
+
this.lastAutoFillShortfall = shortfall
|
|
496
|
+
this.lastAutoFillWarnings = warnings
|
|
497
|
+
this.selection = new Set()
|
|
498
|
+
|
|
499
|
+
const short = this.shortGroups()
|
|
500
|
+
|
|
501
|
+
if (short.length > 0) {
|
|
502
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:short-groups', {
|
|
503
|
+
bubbles: true,
|
|
504
|
+
detail: { groups: short, closed: this.registrationClosed() },
|
|
505
|
+
}))
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
509
|
+
bubbles: true,
|
|
510
|
+
detail: { blocks: this.blocks },
|
|
511
|
+
}))
|
|
512
|
+
|
|
513
|
+
this.render()
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
button.disabled = false
|
|
517
|
+
button.textContent = label
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Empties the whole grid - only ever mutates the in-browser copy
|
|
523
|
+
* until Save is pressed, same as any other change here.
|
|
524
|
+
*/
|
|
525
|
+
clearAll() {
|
|
526
|
+
this.blocks = []
|
|
527
|
+
this.selection = new Set()
|
|
528
|
+
this.lastAutoFillShortfall = null
|
|
529
|
+
this.lastAutoFillWarnings = null
|
|
530
|
+
|
|
531
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
532
|
+
bubbles: true,
|
|
533
|
+
detail: { blocks: this.blocks },
|
|
534
|
+
}))
|
|
535
|
+
|
|
536
|
+
this.render()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Empties just the currently active day tab's cells, leaving every
|
|
541
|
+
* other day untouched.
|
|
542
|
+
*/
|
|
543
|
+
clearDay(date) {
|
|
544
|
+
if (!date) {
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
this.blocks = this.blocks.filter(block => block.date !== date)
|
|
549
|
+
this.selection = new Set()
|
|
550
|
+
|
|
551
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
552
|
+
bubbles: true,
|
|
553
|
+
detail: { blocks: this.blocks },
|
|
554
|
+
}))
|
|
555
|
+
|
|
556
|
+
this.render()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
render() {
|
|
560
|
+
this.$('[data-status]').hidden = true
|
|
561
|
+
this.$('[data-auto-fill]').hidden = false
|
|
562
|
+
this.$('[data-clear-all]').hidden = false
|
|
563
|
+
this.$('[data-clear-day]').hidden = false
|
|
564
|
+
this.$('[data-schedule]').hidden = false
|
|
565
|
+
this.$('[data-save]').hidden = false
|
|
566
|
+
this.$('[data-registration]').hidden = false
|
|
567
|
+
this.$('[data-always-bye]').hidden = false
|
|
568
|
+
this.$('[data-view-toggle]').hidden = false
|
|
569
|
+
|
|
570
|
+
// The plan as data, for reading rather than looking at.
|
|
571
|
+
const showData = this.$('[data-show-data]').checked
|
|
572
|
+
const plan = this.$('[data-plan]')
|
|
573
|
+
|
|
574
|
+
plan.hidden = !showData
|
|
575
|
+
this.$('[data-schedule]').hidden = showData
|
|
576
|
+
|
|
577
|
+
if (showData) {
|
|
578
|
+
plan.textContent = JSON.stringify(this.planData(), null, 2)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Short groups are worth naming on the board, not just in a toast
|
|
583
|
+
// that scrolls away: they are the ones a director can still fill
|
|
584
|
+
// from the waitlist.
|
|
585
|
+
const short = this.shortGroups()
|
|
586
|
+
const shortEl = this.$('[data-short-groups]')
|
|
587
|
+
|
|
588
|
+
if (short.length > 0) {
|
|
589
|
+
shortEl.hidden = false
|
|
590
|
+
shortEl.textContent = this.registrationClosed() && !this.alwaysByeShortGroups()
|
|
591
|
+
? `${short.length} group(s) short their 5th team - now pooled together, registration is closed`
|
|
592
|
+
: this.alwaysByeShortGroups()
|
|
593
|
+
? `${short.length} group(s) short their 5th team - kept their own court regardless`
|
|
594
|
+
: `${short.length} group(s) short their 5th team - held a court each in case the waitlist fills them`
|
|
595
|
+
} else {
|
|
596
|
+
shortEl.hidden = true
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const shortfallEl = this.$('[data-auto-fill-shortfall]')
|
|
600
|
+
if (this.lastAutoFillShortfall?.length) {
|
|
601
|
+
shortfallEl.hidden = false
|
|
602
|
+
shortfallEl.textContent = this.lastAutoFillShortfall
|
|
603
|
+
.map(({ competition_id: competitionId, phase, missing }) => {
|
|
604
|
+
const name = this.state.competitions.find(c => c.id === competitionId)?.name ?? `#${competitionId}`
|
|
605
|
+
return `${name} (${phase}): ${missing} cells short`
|
|
606
|
+
})
|
|
607
|
+
.join(' · ')
|
|
608
|
+
} else {
|
|
609
|
+
shortfallEl.hidden = true
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// What the plan could not honour, or had to guess. A plan that
|
|
613
|
+
// quietly does something other than what it was set up to do is
|
|
614
|
+
// the hardest kind of wrong to spot, so it says so here rather
|
|
615
|
+
// than leaving it to be read off the grid hour by hour.
|
|
616
|
+
const warningsEl = this.$('[data-auto-fill-warnings]')
|
|
617
|
+
|
|
618
|
+
if (this.lastAutoFillWarnings?.length) {
|
|
619
|
+
warningsEl.hidden = false
|
|
620
|
+
warningsEl.textContent = this.lastAutoFillWarnings
|
|
621
|
+
.map(warning => {
|
|
622
|
+
const name = this.state.competitions
|
|
623
|
+
.find(c => c.id === warning.competition_id)?.name ?? `#${warning.competition_id}`
|
|
624
|
+
|
|
625
|
+
if (warning.kind === 'no-template') {
|
|
626
|
+
return `${name}: ${warning.division_name ?? 'a division'}'s group `
|
|
627
|
+
+ `${warning.group_name ?? ''} has only ${warning.teams} team(s) registered - `
|
|
628
|
+
+ `no match schedule exists for that few, so it was not planned at all`
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return `${name} (${warning.phase}, ${warning.date}) was due on at `
|
|
632
|
+
+ `${warning.expected} but opens at ${warning.earliest}`
|
|
633
|
+
})
|
|
634
|
+
.join(' · ')
|
|
635
|
+
} else {
|
|
636
|
+
warningsEl.hidden = true
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
this.renderSchedule()
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
renderSchedule() {
|
|
643
|
+
const container = this.$('[data-schedule]')
|
|
644
|
+
|
|
645
|
+
// Preserve whichever day tab was active - assigning a selection
|
|
646
|
+
// re-renders the whole schedule, and without this it would always
|
|
647
|
+
// snap back to the first day.
|
|
648
|
+
const activeDate = container.querySelector('semtic-tabbed')?.getAttribute('current')
|
|
649
|
+
|
|
650
|
+
container.replaceChildren()
|
|
651
|
+
|
|
652
|
+
if (!this.state.days.length) {
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const tabbed = document.createElement('semtic-tabbed')
|
|
657
|
+
// Open on a day that has something on it. The event's first days
|
|
658
|
+
// are often empty - nothing is planned until a competition's own
|
|
659
|
+
// session starts - and opening on one of those makes a full plan
|
|
660
|
+
// look like an empty board.
|
|
661
|
+
const firstBooked = this.state.days.find(day => this.blocks.some(block => block.date === day))
|
|
662
|
+
const initial = this.state.days.includes(activeDate) ? activeDate : (firstBooked ?? this.state.days[0])
|
|
663
|
+
|
|
664
|
+
tabbed.setAttribute('current', initial)
|
|
665
|
+
|
|
666
|
+
// A day's grid is a full court x slot layout - expensive to build,
|
|
667
|
+
// and a long-running event (a weekly league spanning months) can
|
|
668
|
+
// have far more days than a director ever actually opens in one
|
|
669
|
+
// sitting. So only the initially-open day is built now; every
|
|
670
|
+
// other day is built the moment a director actually switches to
|
|
671
|
+
// it, via semtic-tabbed's `:activated` event - not depended on for
|
|
672
|
+
// the initial day, since that event only fires once semtic-tabbed's
|
|
673
|
+
// real custom element class has upgraded the tag, which a caller
|
|
674
|
+
// that hasn't loaded @skirbi/semtic can't rely on (this package
|
|
675
|
+
// itself does not depend on it - it's supplied by the host app).
|
|
676
|
+
const built = new Set()
|
|
677
|
+
|
|
678
|
+
const buildDay = date => {
|
|
679
|
+
if (built.has(date)) {
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
built.add(date)
|
|
683
|
+
|
|
684
|
+
const tab = tabbed.querySelector(`:scope > semtic-tab[tab="${date}"]`)
|
|
685
|
+
if (!tab) {
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
tab.appendChild(this.legendForDate(date))
|
|
689
|
+
tab.appendChild(this.dayForDate(date))
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
for (const date of this.state.days) {
|
|
693
|
+
const tab = document.createElement('semtic-tab')
|
|
694
|
+
tab.setAttribute('tab', date)
|
|
695
|
+
tab.setAttribute('label', date)
|
|
696
|
+
tabbed.appendChild(tab)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
buildDay(initial)
|
|
700
|
+
|
|
701
|
+
tabbed.addEventListener('semtic-tabbed:activated', event => buildDay(event.detail.tab))
|
|
702
|
+
|
|
703
|
+
container.appendChild(tabbed)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Each competition relevant to this day is a button: select a block
|
|
708
|
+
* of cells first, then click a competition here to assign the whole
|
|
709
|
+
* block to it in one action. Clear does the same for unassigning.
|
|
710
|
+
*/
|
|
711
|
+
legendForDate(date) {
|
|
712
|
+
const legend = document.createElement('ul')
|
|
713
|
+
legend.dataset.legend = ''
|
|
714
|
+
|
|
715
|
+
const clearItem = document.createElement('li')
|
|
716
|
+
const clearButton = document.createElement('button')
|
|
717
|
+
clearButton.type = 'button'
|
|
718
|
+
clearButton.dataset.legendClear = ''
|
|
719
|
+
clearButton.textContent = 'Clear selection'
|
|
720
|
+
clearButton.addEventListener('click', () => this.assignSelection(null))
|
|
721
|
+
clearItem.appendChild(clearButton)
|
|
722
|
+
legend.appendChild(clearItem)
|
|
723
|
+
|
|
724
|
+
for (const { competition, phase } of this.competitionsForDate(date)) {
|
|
725
|
+
const item = document.createElement('li')
|
|
726
|
+
item.dataset.competitionId = competition.id
|
|
727
|
+
item.dataset.phase = phase
|
|
728
|
+
|
|
729
|
+
const button = document.createElement('button')
|
|
730
|
+
button.type = 'button'
|
|
731
|
+
button.dataset.legendAssign = ''
|
|
732
|
+
|
|
733
|
+
const name = document.createElement('span')
|
|
734
|
+
name.dataset.legendName = ''
|
|
735
|
+
name.textContent = `${competition.name} (${phase === 'knockout' ? 'Knockout' : 'Group'})`
|
|
736
|
+
|
|
737
|
+
const count = document.createElement('span')
|
|
738
|
+
count.dataset.legendCount = ''
|
|
739
|
+
// Knockout capacity is planned against the bye-padded bracket
|
|
740
|
+
// shape (games_needed_by_round), not the exact advancing-team
|
|
741
|
+
// math (games_needed.knockout) - a division whose advancing
|
|
742
|
+
// count isn't a power of two always schedules more slots than
|
|
743
|
+
// that exact figure, on purpose, so comparing against it made a
|
|
744
|
+
// fully-planned knockout look short.
|
|
745
|
+
const needed = phase === 'knockout'
|
|
746
|
+
? (competition.games_needed_by_round ?? []).reduce((total, wave) => total + wave.count, 0)
|
|
747
|
+
: competition.games_needed[phase]
|
|
748
|
+
count.textContent = `${this.scheduledGames(competition.id, phase)} scheduled / ${needed} games needed`
|
|
749
|
+
|
|
750
|
+
button.append(name, count)
|
|
751
|
+
button.addEventListener('click', () => this.assignSelection(competition.id, phase))
|
|
752
|
+
|
|
753
|
+
item.appendChild(button)
|
|
754
|
+
legend.appendChild(item)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return legend
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The rows to draw for a day.
|
|
762
|
+
*
|
|
763
|
+
* Competitions no longer share a clock - one starts at 08:00 in
|
|
764
|
+
* 45-minute matches, another at 09:00 in 90-minute ones - so the
|
|
765
|
+
* rows are every time something is actually booked, merged with the
|
|
766
|
+
* standing grid. Drawing only the grid hid every competition whose
|
|
767
|
+
* hours did not happen to land on it.
|
|
768
|
+
*/
|
|
769
|
+
rowsFor(date) {
|
|
770
|
+
return rowsForDate({
|
|
771
|
+
date,
|
|
772
|
+
competitions: this.state.competitions ?? [],
|
|
773
|
+
cells: this.blocks,
|
|
774
|
+
fallbackSlots: this.state.slots ?? [],
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* The stretch of the day worth drawing: from the earliest row's
|
|
780
|
+
* start to the latest row's end. A day is drawn to scale - a
|
|
781
|
+
* Google-Calendar-style timeline, not a table of uniform rows - so
|
|
782
|
+
* the canvas needs real bounds to measure every block's position
|
|
783
|
+
* against.
|
|
784
|
+
*/
|
|
785
|
+
dayRangeFor(rows) {
|
|
786
|
+
if (rows.length === 0) {
|
|
787
|
+
return { start: 0, end: 0 }
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
start: Math.min(...rows.map(row => row.start_minutes)),
|
|
792
|
+
end: Math.max(...rows.map(row => row.end_minutes)),
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The plan as plain data: a day holds courts, a court holds slots, a
|
|
798
|
+
* slot holds what is played in it. The same shape the markup has,
|
|
799
|
+
* without the markup.
|
|
800
|
+
*/
|
|
801
|
+
planData() {
|
|
802
|
+
const activeDate = this.$('[data-schedule] semtic-tabbed')?.getAttribute('current')
|
|
803
|
+
?? this.state.days.find(day => this.blocks.some(block => block.date === day))
|
|
804
|
+
?? this.state.days[0]
|
|
805
|
+
|
|
806
|
+
const competitions = new Map(this.state.competitions.map(c => [c.id, c.name]))
|
|
807
|
+
|
|
808
|
+
const courts = this.state.courts
|
|
809
|
+
.map(court => {
|
|
810
|
+
const slots = this.blocks
|
|
811
|
+
.filter(block => block.date === activeDate && block.court_id === court.id)
|
|
812
|
+
.sort((a, b) => a.slot.localeCompare(b.slot))
|
|
813
|
+
.map(block => ({
|
|
814
|
+
start: block.slot,
|
|
815
|
+
competition: competitions.get(block.competition_id) ?? block.competition_id,
|
|
816
|
+
phase: block.phase,
|
|
817
|
+
division: block.division_name ?? null,
|
|
818
|
+
group: block.group_name ?? null,
|
|
819
|
+
round: block.round ?? null,
|
|
820
|
+
fixture: block.match_slot ?? null,
|
|
821
|
+
shared_pair: block.short_group ? true : undefined,
|
|
822
|
+
}))
|
|
823
|
+
|
|
824
|
+
return slots.length === 0 ? null : { court: court.name, lit: court.lit, slots }
|
|
825
|
+
})
|
|
826
|
+
.filter(Boolean)
|
|
827
|
+
|
|
828
|
+
return { date: activeDate, courts }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The hue this division is drawn in, shared with Scheduling.
|
|
833
|
+
*
|
|
834
|
+
* @see division-hues.mjs
|
|
835
|
+
*/
|
|
836
|
+
divisionHue(competitionId, divisionId) {
|
|
837
|
+
this._divisionHues ??= divisionHues(this.state.competitions)
|
|
838
|
+
|
|
839
|
+
const hue = this._divisionHues.get(divisionId)
|
|
840
|
+
|
|
841
|
+
if (hue != null) {
|
|
842
|
+
return hue
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return competitionHue(
|
|
846
|
+
this.state.competitions.findIndex(c => c.id === competitionId),
|
|
847
|
+
this.state.competitions.length
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* A day, as courts rather than a table.
|
|
853
|
+
*
|
|
854
|
+
* The grid used to be rows of hours crossed with columns of courts,
|
|
855
|
+
* which forced every competition onto one set of rows. They do not
|
|
856
|
+
* share a clock - one starts at 08:00 in 45-minute matches, another
|
|
857
|
+
* at 09:00 in 90-minute ones - so the markup says what is actually
|
|
858
|
+
* true instead: a court holds slots, and a slot holds what is played
|
|
859
|
+
* in it.
|
|
860
|
+
*
|
|
861
|
+
* <planning-court court="8">
|
|
862
|
+
* <planning-slot start="09:00" duration="45">
|
|
863
|
+
* <planning-match competition="Singles" division="adv men" .../>
|
|
864
|
+
*
|
|
865
|
+
* Slots carry the row they occupy so courts still line up across the
|
|
866
|
+
* day without a table to hold them in step.
|
|
867
|
+
*
|
|
868
|
+
* Drawn to scale, Google-Calendar-day-view style: a slot's box is
|
|
869
|
+
* positioned and sized from its real start/end minutes rather than
|
|
870
|
+
* a uniform row height, so a 90-minute match reads twice as tall as
|
|
871
|
+
* a 45-minute one and an idle stretch between two competitions'
|
|
872
|
+
* clocks shows as real blank space instead of being squashed flat
|
|
873
|
+
* against whatever books next. The bookable moments are still
|
|
874
|
+
* exactly the slots each competition's own session configures - the
|
|
875
|
+
* canvas is continuous, the cells on it are not.
|
|
876
|
+
*/
|
|
877
|
+
dayForDate(date) {
|
|
878
|
+
const day = document.createElement('planning-day')
|
|
879
|
+
day.dataset.day = date
|
|
880
|
+
day.setAttribute('date', date)
|
|
881
|
+
|
|
882
|
+
const rows = this.rowsFor(date)
|
|
883
|
+
const range = this.dayRangeFor(rows)
|
|
884
|
+
|
|
885
|
+
day.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
|
|
886
|
+
|
|
887
|
+
day.appendChild(this.timeColumn(rows, range))
|
|
888
|
+
|
|
889
|
+
this.state.courts.forEach((court, courtIndex) => {
|
|
890
|
+
day.appendChild(this.courtColumn(court, courtIndex, date, rows, range))
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
return day
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Minutes of the day, in pixels on the timeline.
|
|
898
|
+
*/
|
|
899
|
+
pxFor(minutes) {
|
|
900
|
+
return minutes * PX_PER_MINUTE
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* The hours down the side. Its own column rather than a cell in
|
|
905
|
+
* every row, so a court knows nothing about how the day is labelled.
|
|
906
|
+
*/
|
|
907
|
+
timeColumn(rows, range) {
|
|
908
|
+
const column = document.createElement('planning-times')
|
|
909
|
+
|
|
910
|
+
// Stands in for the court name, so the hours line up with the
|
|
911
|
+
// slots rather than riding a row above them.
|
|
912
|
+
column.appendChild(document.createElement('planning-times-head'))
|
|
913
|
+
|
|
914
|
+
const body = document.createElement('planning-rows')
|
|
915
|
+
body.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
|
|
916
|
+
|
|
917
|
+
rows.forEach(slot => {
|
|
918
|
+
const time = document.createElement('planning-time')
|
|
919
|
+
time.setAttribute('start', slot.key)
|
|
920
|
+
time.style.top = `${this.pxFor(slot.start_minutes - range.start)}px`
|
|
921
|
+
time.style.height = `${this.pxFor(slot.end_minutes - slot.start_minutes)}px`
|
|
922
|
+
time.textContent = slot.label ?? slot.key
|
|
923
|
+
body.appendChild(time)
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
column.appendChild(body)
|
|
927
|
+
|
|
928
|
+
return column
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
courtColumn(court, courtIndex, date, rows, range) {
|
|
932
|
+
const column = document.createElement('planning-court')
|
|
933
|
+
column.setAttribute('court', court.name)
|
|
934
|
+
column.dataset.courtId = court.id
|
|
935
|
+
|
|
936
|
+
if (court.lit) {
|
|
937
|
+
column.setAttribute('lit', '')
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (court.pro) {
|
|
941
|
+
column.setAttribute('pro', '')
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const heading = document.createElement('planning-court-name')
|
|
945
|
+
heading.textContent = court.name
|
|
946
|
+
column.appendChild(heading)
|
|
947
|
+
|
|
948
|
+
const body = document.createElement('planning-rows')
|
|
949
|
+
body.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
|
|
950
|
+
|
|
951
|
+
// A row's own span is only ever the finest clock running that day
|
|
952
|
+
// (see rowsFor) - a real match longer than that (Team Cup's 90
|
|
953
|
+
// minutes among someone else's 45) draws its own slot taller
|
|
954
|
+
// instead, covering however many of these rows its real length
|
|
955
|
+
// reaches. Those covered rows are this court's alone to skip - a
|
|
956
|
+
// different court still gets its own row here, whatever its own
|
|
957
|
+
// match (or lack of one) actually needs.
|
|
958
|
+
let coveredUntil = -Infinity
|
|
959
|
+
|
|
960
|
+
rows.forEach((slot, rowIndex) => {
|
|
961
|
+
if (slot.start_minutes < coveredUntil) {
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const element = this.slotElement(court, date, slot, rowIndex, courtIndex, range)
|
|
966
|
+
body.appendChild(element)
|
|
967
|
+
|
|
968
|
+
const spanEnd = Number(element.dataset.spanEnd)
|
|
969
|
+
|
|
970
|
+
if (!Number.isNaN(spanEnd)) {
|
|
971
|
+
coveredUntil = spanEnd
|
|
972
|
+
}
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
column.appendChild(body)
|
|
976
|
+
|
|
977
|
+
return column
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* How long the match actually in this slot really runs - its own
|
|
982
|
+
* competition's own configured playtime, not whatever length this
|
|
983
|
+
* row happens to carry (that's only ever the day's finest clock -
|
|
984
|
+
* see rowsFor). An empty slot has no match of its own to ask, so it
|
|
985
|
+
* keeps the row's length exactly as before.
|
|
986
|
+
*/
|
|
987
|
+
blockDurationMinutes(block, date) {
|
|
988
|
+
const competition = this.state.competitions.find(c => c.id === block.competition_id)
|
|
989
|
+
const session = activeSession(competition?.sessions ?? [], date)
|
|
990
|
+
|
|
991
|
+
return session?.playtime ?? FALLBACK_SLOT_MINUTES
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
slotElement(court, date, slot, rowIndex, courtIndex, range) {
|
|
995
|
+
const element = document.createElement('planning-slot')
|
|
996
|
+
const block = this.blockAt(court.id, date, slot.key)
|
|
997
|
+
const duration = block
|
|
998
|
+
? this.blockDurationMinutes(block, date)
|
|
999
|
+
: slot.end_minutes - slot.start_minutes
|
|
1000
|
+
|
|
1001
|
+
element.setAttribute('start', slot.key)
|
|
1002
|
+
element.style.top = `${this.pxFor(slot.start_minutes - range.start)}px`
|
|
1003
|
+
element.style.height = `${this.pxFor(duration)}px`
|
|
1004
|
+
|
|
1005
|
+
if (slot.start_minutes != null) {
|
|
1006
|
+
element.setAttribute('duration', duration)
|
|
1007
|
+
element.dataset.spanEnd = slot.start_minutes + duration
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (!this.isSelectable(court, slot)) {
|
|
1011
|
+
element.setAttribute('blocked', '')
|
|
1012
|
+
return element
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
element.dataset.row = rowIndex
|
|
1016
|
+
element.dataset.col = courtIndex
|
|
1017
|
+
|
|
1018
|
+
element.addEventListener('mousedown', event => {
|
|
1019
|
+
event.preventDefault()
|
|
1020
|
+
this.startSelection(date, rowIndex, courtIndex)
|
|
1021
|
+
})
|
|
1022
|
+
|
|
1023
|
+
element.addEventListener('mouseenter', () => {
|
|
1024
|
+
if (this.isSelecting && this.selectionDate === date) {
|
|
1025
|
+
this.updateSelection(rowIndex, courtIndex)
|
|
1026
|
+
}
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
if (block) {
|
|
1030
|
+
element.appendChild(this.matchElement(block))
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return element
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* What is played in a slot. Everything Planning has decided about it
|
|
1038
|
+
* is an attribute, so the booking can be read straight off the DOM.
|
|
1039
|
+
*/
|
|
1040
|
+
matchElement(block) {
|
|
1041
|
+
const competition = this.state.competitions.find(c => c.id === block.competition_id)
|
|
1042
|
+
const match = document.createElement('planning-match')
|
|
1043
|
+
|
|
1044
|
+
match.setAttribute('competition', competition?.name ?? '')
|
|
1045
|
+
match.setAttribute('phase', block.phase)
|
|
1046
|
+
|
|
1047
|
+
if (block.division_name) {
|
|
1048
|
+
match.setAttribute('division', block.division_name)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// 'x' (mixed/team) and unset both mean there is no single gender
|
|
1052
|
+
// to show - the icon stays off rather than guessing.
|
|
1053
|
+
const gender = block.division_gender === 'm' ? 'men' : block.division_gender === 'f' ? 'women' : null
|
|
1054
|
+
|
|
1055
|
+
if (gender) {
|
|
1056
|
+
match.setAttribute('gender', gender)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (block.group_name) {
|
|
1060
|
+
match.setAttribute('group', block.group_name)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (block.round) {
|
|
1064
|
+
match.setAttribute('round', block.round)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (block.match_slot != null) {
|
|
1068
|
+
match.setAttribute('fixture', block.match_slot)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (block.short_group) {
|
|
1072
|
+
match.setAttribute('shared-pair', '')
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (competition) {
|
|
1076
|
+
match.dataset.competitionId = competition.id
|
|
1077
|
+
|
|
1078
|
+
const hue = this.divisionHue(competition.id, block.division_id)
|
|
1079
|
+
|
|
1080
|
+
if (hue != null) {
|
|
1081
|
+
match.style.setProperty('--division-hue', hue)
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (block.division_id) {
|
|
1086
|
+
match.dataset.divisionId = block.division_id
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const name = document.createElement('planning-competition')
|
|
1090
|
+
name.textContent = competition?.name ?? ''
|
|
1091
|
+
match.appendChild(name)
|
|
1092
|
+
|
|
1093
|
+
if (block.round) {
|
|
1094
|
+
const round = document.createElement('planning-round')
|
|
1095
|
+
round.textContent = block.round
|
|
1096
|
+
match.appendChild(round)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (block.division_name) {
|
|
1100
|
+
const division = document.createElement('planning-division')
|
|
1101
|
+
division.textContent = block.division_name
|
|
1102
|
+
match.appendChild(division)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (block.group_name) {
|
|
1106
|
+
const group = document.createElement('planning-group')
|
|
1107
|
+
group.textContent = block.group_name
|
|
1108
|
+
match.appendChild(group)
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return match
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
async save() {
|
|
1116
|
+
return withBusyButton(this.$('[data-save]'), async () => {
|
|
1117
|
+
|
|
1118
|
+
const config = this.getConfig()
|
|
1119
|
+
|
|
1120
|
+
const blocks = this.blocks.map(block => ({
|
|
1121
|
+
competition_id: block.competition_id,
|
|
1122
|
+
division_id: block.division_id ?? null,
|
|
1123
|
+
group_id: block.group_id ?? null,
|
|
1124
|
+
phase: block.phase,
|
|
1125
|
+
round: block.round ?? null,
|
|
1126
|
+
match_slot: block.match_slot ?? null,
|
|
1127
|
+
court_id: block.court_id,
|
|
1128
|
+
date: block.date,
|
|
1129
|
+
slot: block.slot,
|
|
1130
|
+
}))
|
|
1131
|
+
|
|
1132
|
+
const response = await fetch(config['save-api'], {
|
|
1133
|
+
method: 'POST',
|
|
1134
|
+
headers: {
|
|
1135
|
+
Accept: 'application/json',
|
|
1136
|
+
'Content-Type': 'application/json',
|
|
1137
|
+
'X-CSRF-TOKEN': config['csrf-token'],
|
|
1138
|
+
},
|
|
1139
|
+
body: JSON.stringify({ blocks }),
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
const body = await response.json()
|
|
1143
|
+
|
|
1144
|
+
if (!response.ok) {
|
|
1145
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:save-error', {
|
|
1146
|
+
bubbles: true,
|
|
1147
|
+
detail: body,
|
|
1148
|
+
}))
|
|
1149
|
+
return false
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// The board already holds what it just sent, so the save only has
|
|
1153
|
+
// to confirm. Having the server return the whole plan and
|
|
1154
|
+
// re-rendering from it added several seconds to every save for
|
|
1155
|
+
// nothing - a full plan is over a thousand cells.
|
|
1156
|
+
this.dispatchEvent(new CustomEvent('tennis-planning:save', {
|
|
1157
|
+
bubbles: true,
|
|
1158
|
+
detail: { saved: body },
|
|
1159
|
+
}))
|
|
1160
|
+
|
|
1161
|
+
return true
|
|
1162
|
+
}, SAVING)
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* A row's label reads as the stretch it covers, not the moment it
|
|
1169
|
+
* starts: a 45-minute 09:30 and a 90-minute one are different rows.
|
|
1170
|
+
*/
|
|
1171
|
+
function minutesToTime(minutes) {
|
|
1172
|
+
return `${String(Math.floor(minutes / 60) % 24).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function timeToMinutes(key) {
|
|
1176
|
+
const [hours, minutes] = String(key).split(':').map(Number)
|
|
1177
|
+
return hours * 60 + (minutes || 0)
|
|
1178
|
+
}
|
|
1179
|
+
|