@opndev/tennis-match-center 0.0.2 → 0.0.4
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 +66 -1
- package/lib/auto-planner.mjs +1280 -601
- package/lib/components/day-grid.mjs +19 -7
- package/lib/components/gameday-board.mjs +111 -13
- package/lib/components/gameday-card.mjs +31 -0
- package/lib/components/match.mjs +17 -0
- package/lib/components/planning-board.mjs +233 -8
- package/lib/components/roster-board.mjs +42 -2
- package/lib/components/schedule-board.mjs +10 -6
- package/lib/court-bands.mjs +80 -1
- package/lib/four-team-planner.mjs +2 -2
- package/lib/index.mjs +1 -1
- package/lib/knockout-draw.mjs +32 -6
- package/lib/knockout-planner.mjs +12 -4
- package/lib/paired-court-planner.mjs +65 -2
- package/lib/planning-day.mjs +165 -0
- package/lib/run-queue-planner.mjs +167 -11
- package/lib/sessions.mjs +34 -0
- package/lib/three-team-planner.mjs +2 -1
- package/package.json +1 -1
|
@@ -72,7 +72,7 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
72
72
|
this.dataset.day = date
|
|
73
73
|
this.setAttribute('date', date)
|
|
74
74
|
this.toggleAttribute('compact', compact)
|
|
75
|
-
this.style.setProperty('--planning-day-height',
|
|
75
|
+
this.style.setProperty('--planning-day-height', this.lengthFor(range.end - range.start))
|
|
76
76
|
|
|
77
77
|
this.replaceChildren(
|
|
78
78
|
this.timeColumn(rows, range),
|
|
@@ -94,14 +94,14 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
94
94
|
column.appendChild(document.createElement('planning-times-head'))
|
|
95
95
|
|
|
96
96
|
const body = document.createElement('planning-rows')
|
|
97
|
-
body.style.setProperty('--planning-day-height',
|
|
97
|
+
body.style.setProperty('--planning-day-height', this.lengthFor(range.end - range.start))
|
|
98
98
|
|
|
99
99
|
for (const slot of rows) {
|
|
100
100
|
const time = document.createElement('planning-time')
|
|
101
101
|
|
|
102
102
|
time.setAttribute('start', slot.key)
|
|
103
|
-
time.style.top =
|
|
104
|
-
time.style.height =
|
|
103
|
+
time.style.top = this.lengthFor(slot.start_minutes - range.start)
|
|
104
|
+
time.style.height = this.lengthFor(slot.end_minutes - slot.start_minutes)
|
|
105
105
|
time.textContent = slot.label ?? slot.key
|
|
106
106
|
|
|
107
107
|
column.appendChild(body).appendChild(time)
|
|
@@ -129,7 +129,7 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
129
129
|
column.appendChild(heading)
|
|
130
130
|
|
|
131
131
|
const body = document.createElement('planning-rows')
|
|
132
|
-
body.style.setProperty('--planning-day-height',
|
|
132
|
+
body.style.setProperty('--planning-day-height', this.lengthFor(range.end - range.start))
|
|
133
133
|
|
|
134
134
|
// A row's own span is only ever the finest clock running that day
|
|
135
135
|
// - a real match longer than that (Team Cup's 90 minutes among
|
|
@@ -172,8 +172,8 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
172
172
|
const minutes = cell.minutes ?? (slot.end_minutes - slot.start_minutes)
|
|
173
173
|
|
|
174
174
|
element.setAttribute('start', slot.key)
|
|
175
|
-
element.style.top =
|
|
176
|
-
element.style.height =
|
|
175
|
+
element.style.top = this.lengthFor(slot.start_minutes - range.start)
|
|
176
|
+
element.style.height = this.lengthFor(minutes)
|
|
177
177
|
|
|
178
178
|
if (slot.start_minutes != null) {
|
|
179
179
|
element.setAttribute('duration', minutes)
|
|
@@ -235,6 +235,18 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
235
235
|
return minutes * (this.scale ?? PX_PER_MINUTE)
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The same size as a CSS length that follows --planning-scale, so a
|
|
240
|
+
* host can make the whole day more compact (or roomier) with one
|
|
241
|
+
* variable, and a day already drawn follows it without a redraw.
|
|
242
|
+
*
|
|
243
|
+
* @param {number} minutes
|
|
244
|
+
* @returns {string}
|
|
245
|
+
*/
|
|
246
|
+
lengthFor(minutes) {
|
|
247
|
+
return `calc(${this.pxFor(minutes)}px * var(--planning-scale, 1))`
|
|
248
|
+
}
|
|
249
|
+
|
|
238
250
|
/**
|
|
239
251
|
* Shows what is being dragged and where it would land. The boards
|
|
240
252
|
* decide what a drag means; showing one is the same job on both.
|
|
@@ -138,12 +138,12 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
138
138
|
confirm-label="Play"
|
|
139
139
|
show-confirm="true">
|
|
140
140
|
<p data-next-said></p>
|
|
141
|
-
<p data-waiting-label></p>
|
|
142
|
-
<p data-waiting-head></p>
|
|
143
|
-
<p data-waiting-match></p>
|
|
144
141
|
<p data-queued-label></p>
|
|
145
142
|
<p data-queued-head></p>
|
|
146
143
|
<p data-queued-match></p>
|
|
144
|
+
<p data-waiting-label></p>
|
|
145
|
+
<p data-waiting-head></p>
|
|
146
|
+
<p data-waiting-match></p>
|
|
147
147
|
</semtic-modal>
|
|
148
148
|
<semtic-modal data-busy-modal hidden
|
|
149
149
|
header="That court is taken"
|
|
@@ -200,6 +200,14 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
200
200
|
this.recordScore(event.detail)
|
|
201
201
|
})
|
|
202
202
|
|
|
203
|
+
this.addEventListener('gameday-card:enter', event => {
|
|
204
|
+
this.autoScore(event.detail)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
this.addEventListener('gameday-card:leave', event => {
|
|
208
|
+
this.autoPrefill(event.detail)
|
|
209
|
+
})
|
|
210
|
+
|
|
203
211
|
this.addEventListener('gameday-card:court', event => {
|
|
204
212
|
this.courtDialog.open(event.detail.match)
|
|
205
213
|
})
|
|
@@ -285,10 +293,27 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
285
293
|
|
|
286
294
|
this.nextDialog = new GamedayDialog(this.$('[data-next-modal]'), {
|
|
287
295
|
fill: match => this.fillNext(match),
|
|
288
|
-
focus: modal => modal.querySelector('[data-next-modal] input')
|
|
296
|
+
focus: modal => modal.querySelector('[data-next-modal] input:checked')
|
|
297
|
+
?? modal.querySelector('[data-next-modal] input')
|
|
298
|
+
?? modal.querySelector('[semtic-modal-cancel]'),
|
|
289
299
|
confirmed: match => this.playPicked(this.effectiveCourtId(match)),
|
|
290
300
|
})
|
|
291
301
|
|
|
302
|
+
// Enter is Play. A held Enter repeats into the dialog that the first
|
|
303
|
+
// one opened, so only a fresh press counts.
|
|
304
|
+
this.$('[data-next-modal]').addEventListener('keydown', event => {
|
|
305
|
+
const confirm = this.$('[data-next-modal] [semtic-modal-confirm]')
|
|
306
|
+
|
|
307
|
+
if (event.key !== 'Enter' || event.repeat || event.target.closest('button')) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (confirm && !confirm.hasAttribute('disabled')) {
|
|
312
|
+
event.preventDefault()
|
|
313
|
+
confirm.click()
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
292
317
|
this.busyDialog = new GamedayDialog(this.$('[data-busy-modal]'), {
|
|
293
318
|
fill: match => this.fillBusy(match),
|
|
294
319
|
focus: modal => modal.querySelector('[data-busy-select] select')
|
|
@@ -871,6 +896,78 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
871
896
|
match.sets[index][`${side}_games`] = value === '' ? null : Number(value)
|
|
872
897
|
}
|
|
873
898
|
|
|
899
|
+
/**
|
|
900
|
+
* One side has a score under seven and the other is empty: the other
|
|
901
|
+
* side gets seven.
|
|
902
|
+
*
|
|
903
|
+
* @param {object} match
|
|
904
|
+
* @param {number} set
|
|
905
|
+
* @returns {?string} the side that was filled in, if any
|
|
906
|
+
*/
|
|
907
|
+
autoFill(match, set) {
|
|
908
|
+
const row = match.sets[set - 1]
|
|
909
|
+
|
|
910
|
+
if (!row) {
|
|
911
|
+
return null
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const home = row.home_games ?? null
|
|
915
|
+
const visitor = row.visitor_games ?? null
|
|
916
|
+
|
|
917
|
+
if (home !== null && visitor === null && home < 7) {
|
|
918
|
+
row.visitor_games = 7
|
|
919
|
+
|
|
920
|
+
return 'visitor'
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (visitor !== null && home === null && visitor < 7) {
|
|
924
|
+
row.home_games = 7
|
|
925
|
+
|
|
926
|
+
return 'home'
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return null
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Enter in a score box ends the match, after the auto score.
|
|
934
|
+
*
|
|
935
|
+
* @param {{match: object, set: number}} entered
|
|
936
|
+
*/
|
|
937
|
+
async autoScore({ match, set }) {
|
|
938
|
+
if (!match || this.segmentOf(match) === 'finished') {
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
this.autoFill(match, set)
|
|
943
|
+
|
|
944
|
+
if (this.hasScore(match)) {
|
|
945
|
+
await this.toggleFinished(match)
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Focus left the score boxes: prefill the seven and leave the
|
|
951
|
+
* ending to the desk. The box is written directly, since a redraw
|
|
952
|
+
* under the pointer would swallow a click on the end button.
|
|
953
|
+
*
|
|
954
|
+
* @param {{match: object, set: number}} left
|
|
955
|
+
*/
|
|
956
|
+
autoPrefill({ match, set }) {
|
|
957
|
+
if (!match || this.segmentOf(match) === 'finished') {
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const side = this.autoFill(match, set)
|
|
962
|
+
const box = side && this.$(
|
|
963
|
+
`[data-match-id="${match.id}"] [data-side="${side}"] input[data-set="${set}"]`
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
if (box) {
|
|
967
|
+
box.value = '7'
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
874
971
|
/**
|
|
875
972
|
* @param {object} match
|
|
876
973
|
*/
|
|
@@ -1541,15 +1638,15 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
1541
1638
|
// saying it here.
|
|
1542
1639
|
const longest = said.longest
|
|
1543
1640
|
|
|
1544
|
-
this.sayMatchBlock('waiting', longest, 'Longest waiting match')
|
|
1545
|
-
|
|
1546
1641
|
// One match, said once. When the longest wait is this court's own
|
|
1547
1642
|
// queue, a second block would be the same two names again.
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1643
|
+
const queued = said.next && said.next !== longest ? said.next : null
|
|
1644
|
+
|
|
1645
|
+
this.sayMatchBlock('queued', queued, `Next planned match on ${court}`, true)
|
|
1646
|
+
|
|
1647
|
+
// What was planned for this court is the default. The longest wait
|
|
1648
|
+
// is the alternative, or the only choice when nothing is queued here.
|
|
1649
|
+
this.sayMatchBlock('waiting', longest, 'Longest waiting match', !queued)
|
|
1553
1650
|
|
|
1554
1651
|
// Nothing is waiting anywhere, so there is nothing to put on.
|
|
1555
1652
|
this.$('[data-next-modal] [semtic-modal-confirm]')
|
|
@@ -1600,8 +1697,9 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
1600
1697
|
* @param {string} role - 'waiting' or 'queued'
|
|
1601
1698
|
* @param {object|null} match
|
|
1602
1699
|
* @param {string} label
|
|
1700
|
+
* @param {boolean} checked - whether it is the default pick
|
|
1603
1701
|
*/
|
|
1604
|
-
sayMatchBlock(role, match, label) {
|
|
1702
|
+
sayMatchBlock(role, match, label, checked) {
|
|
1605
1703
|
for (const part of ['label', 'head', 'match']) {
|
|
1606
1704
|
this.$(`[data-${role}-${part}]`).hidden = !match
|
|
1607
1705
|
}
|
|
@@ -1617,7 +1715,7 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
1617
1715
|
'next-play',
|
|
1618
1716
|
String(match.id),
|
|
1619
1717
|
label,
|
|
1620
|
-
|
|
1718
|
+
checked,
|
|
1621
1719
|
'right'
|
|
1622
1720
|
))
|
|
1623
1721
|
|
|
@@ -38,6 +38,8 @@ import { scoreField, teamCell, textTag } from '../gameday-parts.mjs'
|
|
|
38
38
|
* Fired, all bubbling, all carrying `{ match }`:
|
|
39
39
|
*
|
|
40
40
|
* gameday-card:score a score box was typed in
|
|
41
|
+
* gameday-card:enter Enter was pressed in a score box
|
|
42
|
+
* gameday-card:leave focus left the score boxes
|
|
41
43
|
* gameday-card:action end the match, or reopen it
|
|
42
44
|
* gameday-card:start start the match, or take that back
|
|
43
45
|
* gameday-card:court the court was asked about
|
|
@@ -81,6 +83,35 @@ export class TennisGamedayCardElement extends HTMLElementSugar {
|
|
|
81
83
|
}
|
|
82
84
|
})
|
|
83
85
|
|
|
86
|
+
this.addEventListener('keydown', event => {
|
|
87
|
+
const input = event.target.closest?.('[data-scores] input')
|
|
88
|
+
|
|
89
|
+
if (input && event.key === 'Enter') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
this.say('enter', {
|
|
92
|
+
set: Number(input.dataset.set),
|
|
93
|
+
side: input.closest('[data-scores]').dataset.side,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
this.addEventListener('focusout', event => {
|
|
99
|
+
const input = event.target.closest?.('[data-scores] input')
|
|
100
|
+
const next = event.relatedTarget
|
|
101
|
+
|
|
102
|
+
if (!input) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!next && this.ownerDocument.hasFocus?.() === false) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.say('leave', {
|
|
111
|
+
set: Number(input.dataset.set),
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
84
115
|
this.addEventListener('click', event => {
|
|
85
116
|
const pressed = event.target.closest(
|
|
86
117
|
'[data-court-open], [data-start], [data-action]'
|
package/lib/components/match.mjs
CHANGED
|
@@ -39,6 +39,8 @@ export class TennisMatchElement extends withConnectedSucu(HTMLElementSugar) {
|
|
|
39
39
|
ended: { default: null },
|
|
40
40
|
'on-court': { default: null },
|
|
41
41
|
status: { default: '' },
|
|
42
|
+
state: { default: '' },
|
|
43
|
+
'not-before': { default: '' },
|
|
42
44
|
winner: { default: '' },
|
|
43
45
|
hue: { default: '' },
|
|
44
46
|
}
|
|
@@ -159,6 +161,21 @@ export class TennisMatchElement extends withConnectedSucu(HTMLElementSugar) {
|
|
|
159
161
|
return named[status]
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
// Said by the host when it knows, because next up depends on the
|
|
165
|
+
// other matches on the court and a card cannot see those.
|
|
166
|
+
const state = String(cfg.state || '').trim()
|
|
167
|
+
|
|
168
|
+
if (state) {
|
|
169
|
+
const notBefore = String(cfg['not-before'] || '').trim()
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
final: 'Final',
|
|
173
|
+
on_court: 'On court',
|
|
174
|
+
next_up: 'Go to court',
|
|
175
|
+
pending: notBefore ? `Not before ${notBefore}` : '',
|
|
176
|
+
}[state] ?? ''
|
|
177
|
+
}
|
|
178
|
+
|
|
162
179
|
// hasAttribute, not the config: an absent attribute comes back as
|
|
163
180
|
// undefined rather than the declared null.
|
|
164
181
|
if (this.hasAttribute('ended')) {
|
|
@@ -136,8 +136,14 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
136
136
|
<input data-always-bye-short-groups type="checkbox">
|
|
137
137
|
Always give a short group its bye
|
|
138
138
|
</label>
|
|
139
|
+
<label data-relaxed hidden>
|
|
140
|
+
<input data-relaxed-sharing type="checkbox">
|
|
141
|
+
Share courts when they run short (clear the plan first)
|
|
142
|
+
</label>
|
|
139
143
|
<p data-short-groups hidden></p>
|
|
140
144
|
<p data-auto-fill-shortfall hidden></p>
|
|
145
|
+
<p data-auto-fill-split hidden></p>
|
|
146
|
+
<p data-auto-fill-starts hidden></p>
|
|
141
147
|
<p data-auto-fill-warnings hidden></p>
|
|
142
148
|
<button data-clear-all type="button" hidden>Clear planning (all)</button>
|
|
143
149
|
<button data-clear-day type="button" hidden>Clear planning (day)</button>
|
|
@@ -182,6 +188,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
182
188
|
// replacing the grid.
|
|
183
189
|
this.$('[data-registration-closed]').addEventListener('change', () => this.render())
|
|
184
190
|
this.$('[data-always-bye-short-groups]').addEventListener('change', () => this.render())
|
|
191
|
+
this.$('[data-relaxed-sharing]').addEventListener('change', () => this.render())
|
|
185
192
|
this.$('[data-show-data]').addEventListener('change', () => this.render())
|
|
186
193
|
this.$('[data-narrow-empty]').addEventListener('change', () => this.render())
|
|
187
194
|
this.$('[data-clear-all]').addEventListener('click', () => this.clearAll())
|
|
@@ -239,7 +246,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
239
246
|
return
|
|
240
247
|
}
|
|
241
248
|
|
|
242
|
-
this.blocks = this.state.blocks.map(block => ({ ...block }))
|
|
249
|
+
this.blocks = this.withPlayed(this.state.blocks.map(block => ({ ...block })))
|
|
243
250
|
this.selection = new Set()
|
|
244
251
|
this.selectionDate = null
|
|
245
252
|
this.isSelecting = false
|
|
@@ -736,6 +743,60 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
736
743
|
this.paintSelection()
|
|
737
744
|
}
|
|
738
745
|
|
|
746
|
+
/**
|
|
747
|
+
* The saved plan with what actually happened written over it.
|
|
748
|
+
*
|
|
749
|
+
* The plan is what was intended. The matches are what was played,
|
|
750
|
+
* on the court and at the hour they really ran, and the two part
|
|
751
|
+
* company the moment the plan is changed or a session disagrees with
|
|
752
|
+
* the day - Monday opened at 08:00 where the sessions said 09:00, and
|
|
753
|
+
* not one cell of 2166 lined up.
|
|
754
|
+
*
|
|
755
|
+
* So a played cell wins twice over: the plan's cell for that same
|
|
756
|
+
* fixture goes, wherever it had put it, and so does whatever the
|
|
757
|
+
* plan had on that court at that hour.
|
|
758
|
+
*
|
|
759
|
+
* @param {Array} blocks
|
|
760
|
+
* @returns {Array}
|
|
761
|
+
*/
|
|
762
|
+
withPlayed(blocks) {
|
|
763
|
+
const played = this.state.played_cells ?? []
|
|
764
|
+
|
|
765
|
+
if (played.length === 0) {
|
|
766
|
+
return blocks
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const fixtures = new Set(played.map(cell => this.fixtureKey(cell)))
|
|
770
|
+
const where = new Set(played.map(cell => `${cell.date}|${cell.court_id}|${cell.slot}`))
|
|
771
|
+
|
|
772
|
+
return [
|
|
773
|
+
...blocks.filter(block =>
|
|
774
|
+
!fixtures.has(this.fixtureKey(block))
|
|
775
|
+
&& !where.has(`${block.date}|${block.court_id}|${block.slot}`)
|
|
776
|
+
),
|
|
777
|
+
...played.map(cell => ({ ...cell, id: null })),
|
|
778
|
+
]
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* What a cell is for, as far as two cells can be said to be the
|
|
783
|
+
* same one: the fixture, not where it was put.
|
|
784
|
+
*
|
|
785
|
+
* @param {Object} cell
|
|
786
|
+
* @returns {string}
|
|
787
|
+
*/
|
|
788
|
+
fixtureKey(cell) {
|
|
789
|
+
// A group fixture is its group and its slot; a knockout one is
|
|
790
|
+
// its round and its slot. The phase decides which, because a
|
|
791
|
+
// planned group cell carries no round and a played one says
|
|
792
|
+
// 'group', and they are the same fixture.
|
|
793
|
+
const of = cell.phase === 'knockout'
|
|
794
|
+
? [cell.round ?? '', cell.match_slot ?? '']
|
|
795
|
+
: [cell.group_id ?? '', cell.match_slot ?? '']
|
|
796
|
+
|
|
797
|
+
return [cell.competition_id, cell.division_id ?? '', cell.phase, ...of].join('|')
|
|
798
|
+
}
|
|
799
|
+
|
|
739
800
|
/**
|
|
740
801
|
* The blocks under the current selection.
|
|
741
802
|
*
|
|
@@ -1170,6 +1231,18 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1170
1231
|
return this.$('[data-always-bye-short-groups]')?.checked ?? false
|
|
1171
1232
|
}
|
|
1172
1233
|
|
|
1234
|
+
/**
|
|
1235
|
+
* Whether groups may share courts when the courts run short.
|
|
1236
|
+
*
|
|
1237
|
+
* Off, a group stays on one court for its whole run and what does not
|
|
1238
|
+
* fit is reported. On, a plan that falls short is planned again with
|
|
1239
|
+
* courts shared. The board only adds to what is on it, so a plan made
|
|
1240
|
+
* strictly has to be cleared before sharing can rearrange it.
|
|
1241
|
+
*/
|
|
1242
|
+
relaxed() {
|
|
1243
|
+
return this.$('[data-relaxed-sharing]')?.checked ?? false
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1173
1246
|
/**
|
|
1174
1247
|
* Left off the element, the planner's own default stands. "0" is a
|
|
1175
1248
|
* real answer, hence no `|| undefined`.
|
|
@@ -1202,6 +1275,11 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1202
1275
|
}
|
|
1203
1276
|
|
|
1204
1277
|
/**
|
|
1278
|
+
* Fills in what the sessions ask for and the grid does not hold yet.
|
|
1279
|
+
*
|
|
1280
|
+
* It only ever adds: what is on the board stands, and the planner
|
|
1281
|
+
* works around it. To plan a day afresh, clear it first.
|
|
1282
|
+
*
|
|
1205
1283
|
* Re-fetches the competitions/groups config before computing, so a
|
|
1206
1284
|
* pairing (or any other config) changed on the Competitions page
|
|
1207
1285
|
* just before clicking this is what actually gets planned against -
|
|
@@ -1223,13 +1301,14 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1223
1301
|
return
|
|
1224
1302
|
}
|
|
1225
1303
|
|
|
1226
|
-
const { blocks, shortfall, warnings } = autoFillPlanning({
|
|
1304
|
+
const { blocks, shortfall, warnings, split } = autoFillPlanning({
|
|
1227
1305
|
courts: this.state.courts,
|
|
1228
1306
|
slots: this.state.slots,
|
|
1229
1307
|
days: this.state.days,
|
|
1230
1308
|
competitions: this.state.competitions,
|
|
1231
1309
|
registrationClosed: this.registrationClosed(),
|
|
1232
1310
|
alwaysByeShortGroups: this.alwaysByeShortGroups(),
|
|
1311
|
+
relaxed: this.relaxed(),
|
|
1233
1312
|
courtOverrides: this.state.court_overrides ?? [],
|
|
1234
1313
|
|
|
1235
1314
|
// Group play follows each competition's own session, so a
|
|
@@ -1240,11 +1319,21 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1240
1319
|
darkFrom: this.getConfig()['dark-from'],
|
|
1241
1320
|
dayEnd: this.getConfig()['day-end'] || undefined,
|
|
1242
1321
|
lightBuffer: this.lightBuffer(),
|
|
1322
|
+
standing: this.blocks,
|
|
1243
1323
|
})
|
|
1244
1324
|
|
|
1245
|
-
|
|
1325
|
+
// The planner hands back the cells it was given on the days it
|
|
1326
|
+
// planned, along with what it added. A cell on a day outside the
|
|
1327
|
+
// canvas never reaches it.
|
|
1328
|
+
const canvas = new Set(this.state.days)
|
|
1329
|
+
|
|
1330
|
+
this.blocks = [
|
|
1331
|
+
...this.blocks.filter(block => !canvas.has(block.date)),
|
|
1332
|
+
...blocks,
|
|
1333
|
+
]
|
|
1246
1334
|
this.lastAutoFillShortfall = shortfall
|
|
1247
1335
|
this.lastAutoFillWarnings = warnings
|
|
1336
|
+
this.lastAutoFillSplit = split ?? []
|
|
1248
1337
|
this.selection = new Set()
|
|
1249
1338
|
|
|
1250
1339
|
const short = this.shortGroups()
|
|
@@ -1270,14 +1359,106 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1270
1359
|
}
|
|
1271
1360
|
|
|
1272
1361
|
/**
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1362
|
+
* Which groups ended up on more than one court, and where.
|
|
1363
|
+
*
|
|
1364
|
+
* A group is named by its competition, division and letter: the
|
|
1365
|
+
* letter alone is in every division. Then the court it started on,
|
|
1366
|
+
* the court it moved to and when it first plays there.
|
|
1367
|
+
*
|
|
1368
|
+
* @param {Array<{competition_id: number, group_name: ?string,
|
|
1369
|
+
* group_id: number, division_name: ?string, date: string,
|
|
1370
|
+
* courts: number[], moves_at: ?string}>} split
|
|
1371
|
+
* @returns {string}
|
|
1372
|
+
*/
|
|
1373
|
+
splitSummary(split) {
|
|
1374
|
+
const courtName = id => this.state.courts.find(court => court.id === id)?.name ?? id
|
|
1375
|
+
|
|
1376
|
+
const named = split.slice(0, 6).map(entry => {
|
|
1377
|
+
const competition = this.state.competitions
|
|
1378
|
+
.find(candidate => candidate.id === entry.competition_id)?.name
|
|
1379
|
+
const group = entry.group_name ?? `#${entry.group_id}`
|
|
1380
|
+
const whom = [competition, entry.division_name].filter(Boolean).join(': ')
|
|
1381
|
+
const [from, ...to] = entry.courts.map(courtName)
|
|
1382
|
+
const moved = to.length
|
|
1383
|
+
? `${from}, then ${to.join(', then ')}${entry.moves_at ? ` from ${entry.moves_at}` : ''}`
|
|
1384
|
+
: from
|
|
1385
|
+
|
|
1386
|
+
return `${whom}, group ${group} (${entry.date.slice(5)}): ${moved}`
|
|
1387
|
+
})
|
|
1388
|
+
|
|
1389
|
+
const more = split.length > named.length
|
|
1390
|
+
? `, and ${split.length - named.length} more`
|
|
1391
|
+
: ''
|
|
1392
|
+
|
|
1393
|
+
return `${split.length} group(s) share courts: ${named.join(' · ')}${more}`
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* How many groups of each competition start in each block of the
|
|
1398
|
+
* day, one line a day: "Singles 6/4/6".
|
|
1399
|
+
*
|
|
1400
|
+
* Where courts are shared it is the number to look at. Each category
|
|
1401
|
+
* should spread as evenly as it can over the blocks, and a group's
|
|
1402
|
+
* block is the hour its first match starts.
|
|
1403
|
+
*
|
|
1404
|
+
* @returns {string[]}
|
|
1405
|
+
*/
|
|
1406
|
+
startSpread() {
|
|
1407
|
+
const first = new Map()
|
|
1408
|
+
|
|
1409
|
+
for (const block of this.blocks) {
|
|
1410
|
+
if (block.phase !== 'group' || block.group_id == null) {
|
|
1411
|
+
continue
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const key = [block.competition_id, block.date, block.group_id].join('|')
|
|
1415
|
+
const held = first.get(key)
|
|
1416
|
+
|
|
1417
|
+
if (!held || block.slot < held.slot) {
|
|
1418
|
+
first.set(key, block)
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const days = new Map()
|
|
1423
|
+
|
|
1424
|
+
for (const block of first.values()) {
|
|
1425
|
+
const day = days.get(block.date) ?? new Map()
|
|
1426
|
+
const starts = day.get(block.competition_id) ?? new Map()
|
|
1427
|
+
|
|
1428
|
+
starts.set(block.slot, (starts.get(block.slot) ?? 0) + 1)
|
|
1429
|
+
day.set(block.competition_id, starts)
|
|
1430
|
+
days.set(block.date, day)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
return [...days.keys()].sort().map(date => {
|
|
1434
|
+
const spread = [...days.get(date)].map(([competitionId, starts]) => {
|
|
1435
|
+
const name = this.state.competitions
|
|
1436
|
+
.find(competition => competition.id === competitionId)?.name ?? `#${competitionId}`
|
|
1437
|
+
const counts = [...starts.keys()].sort().map(slot => starts.get(slot))
|
|
1438
|
+
|
|
1439
|
+
return `${name} ${counts.join('/')}`
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
return `${date}: ${spread.join(' · ')}`
|
|
1443
|
+
})
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Empties the grid, bar what was played.
|
|
1448
|
+
*
|
|
1449
|
+
* A day that has happened cannot be unplanned: clearing it would
|
|
1450
|
+
* leave the matches on it with no cell to sit in, and the next
|
|
1451
|
+
* auto-fill would hand their courts to somebody else.
|
|
1452
|
+
*
|
|
1453
|
+
* Only ever mutates the in-browser copy until Save is pressed, same
|
|
1454
|
+
* as any other change here.
|
|
1275
1455
|
*/
|
|
1276
1456
|
clearAll() {
|
|
1277
|
-
this.blocks =
|
|
1457
|
+
this.blocks = this.blocks.filter(block => block.played === true)
|
|
1278
1458
|
this.selection = new Set()
|
|
1279
1459
|
this.lastAutoFillShortfall = null
|
|
1280
1460
|
this.lastAutoFillWarnings = null
|
|
1461
|
+
this.lastAutoFillSplit = null
|
|
1281
1462
|
|
|
1282
1463
|
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
1283
1464
|
bubbles: true,
|
|
@@ -1289,14 +1470,18 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1289
1470
|
|
|
1290
1471
|
/**
|
|
1291
1472
|
* Empties just the currently active day tab's cells, leaving every
|
|
1292
|
-
* other day untouched
|
|
1473
|
+
* other day untouched - and leaving what was played on it, because
|
|
1474
|
+
* a cell a match was played in is a record of the day rather than a
|
|
1475
|
+
* plan for it.
|
|
1293
1476
|
*/
|
|
1294
1477
|
clearDay(date) {
|
|
1295
1478
|
if (!date) {
|
|
1296
1479
|
return
|
|
1297
1480
|
}
|
|
1298
1481
|
|
|
1299
|
-
this.blocks = this.blocks.filter(block =>
|
|
1482
|
+
this.blocks = this.blocks.filter(block =>
|
|
1483
|
+
block.date !== date || block.played === true
|
|
1484
|
+
)
|
|
1300
1485
|
this.selection = new Set()
|
|
1301
1486
|
|
|
1302
1487
|
this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
|
|
@@ -1316,6 +1501,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1316
1501
|
this.$('[data-save]').hidden = false
|
|
1317
1502
|
this.$('[data-registration]').hidden = false
|
|
1318
1503
|
this.$('[data-always-bye]').hidden = false
|
|
1504
|
+
this.$('[data-relaxed]').hidden = false
|
|
1319
1505
|
this.$('[data-view-toggle]').hidden = false
|
|
1320
1506
|
this.$('[data-narrow-toggle]').hidden = false
|
|
1321
1507
|
|
|
@@ -1326,6 +1512,15 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1326
1512
|
plan.hidden = !showData
|
|
1327
1513
|
this.$('[data-schedule]').hidden = showData
|
|
1328
1514
|
|
|
1515
|
+
// How the groups spread over the blocks of the day is for reading
|
|
1516
|
+
// the plan as data, not for every Auto-fill.
|
|
1517
|
+
const startsEl = this.$('[data-auto-fill-starts]')
|
|
1518
|
+
const starts = showData ? this.startSpread() : []
|
|
1519
|
+
|
|
1520
|
+
startsEl.hidden = starts.length === 0
|
|
1521
|
+
startsEl.style.whiteSpace = 'pre-line'
|
|
1522
|
+
startsEl.textContent = starts.join('\n')
|
|
1523
|
+
|
|
1329
1524
|
if (showData) {
|
|
1330
1525
|
plan.textContent = JSON.stringify(this.planData(), null, 2)
|
|
1331
1526
|
return
|
|
@@ -1361,6 +1556,15 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1361
1556
|
shortfallEl.hidden = true
|
|
1362
1557
|
}
|
|
1363
1558
|
|
|
1559
|
+
const splitEl = this.$('[data-auto-fill-split]')
|
|
1560
|
+
|
|
1561
|
+
if (this.lastAutoFillSplit?.length) {
|
|
1562
|
+
splitEl.hidden = false
|
|
1563
|
+
splitEl.textContent = this.splitSummary(this.lastAutoFillSplit)
|
|
1564
|
+
} else {
|
|
1565
|
+
splitEl.hidden = true
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1364
1568
|
// What the plan could not honour, or had to guess. A plan that
|
|
1365
1569
|
// quietly does something other than what it was set up to do is
|
|
1366
1570
|
// the hardest kind of wrong to spot, so it says so here rather
|
|
@@ -1380,6 +1584,21 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1380
1584
|
+ `no match schedule exists for that few, so it was not planned at all`
|
|
1381
1585
|
}
|
|
1382
1586
|
|
|
1587
|
+
if (warning.kind === 'not-shareable') {
|
|
1588
|
+
return `${name}: sharing courts cannot help, it only works for `
|
|
1589
|
+
+ `groups of five`
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (warning.kind === 'no-sessions') {
|
|
1593
|
+
return `${name}: has matches to play but no sessions, so it was `
|
|
1594
|
+
+ `not planned at all`
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
if (warning.kind === 'outside-days') {
|
|
1598
|
+
return `${name}: has sessions on ${warning.dates.join(', ')}, which `
|
|
1599
|
+
+ `are not days of this event, so nothing was planned for them`
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1383
1602
|
return `${name} (${warning.phase}, ${warning.date}) was due on at `
|
|
1384
1603
|
+ `${warning.expected} but opens at ${warning.earliest}`
|
|
1385
1604
|
})
|
|
@@ -1847,6 +2066,12 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1847
2066
|
match.setAttribute('round', block.round)
|
|
1848
2067
|
}
|
|
1849
2068
|
|
|
2069
|
+
// Played, and therefore not the planner's to move: it is where it
|
|
2070
|
+
// is because that is where it happened.
|
|
2071
|
+
if (block.played === true) {
|
|
2072
|
+
match.setAttribute('played', '')
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1850
2075
|
if (block.match_slot != null) {
|
|
1851
2076
|
match.setAttribute('fixture', block.match_slot)
|
|
1852
2077
|
}
|