@opndev/tennis-match-center 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+