@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.
@@ -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', `${this.pxFor(range.end - range.start)}px`)
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', `${this.pxFor(range.end - range.start)}px`)
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 = `${this.pxFor(slot.start_minutes - range.start)}px`
104
- time.style.height = `${this.pxFor(slot.end_minutes - slot.start_minutes)}px`
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', `${this.pxFor(range.end - range.start)}px`)
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 = `${this.pxFor(slot.start_minutes - range.start)}px`
176
- element.style.height = `${this.pxFor(minutes)}px`
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') ?? modal.querySelector('[semtic-modal-cancel]'),
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
- this.sayMatchBlock(
1549
- 'queued',
1550
- said.next && said.next !== longest ? said.next : null,
1551
- `Next planned match on ${court}`
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
- role === 'waiting',
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]'
@@ -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
- this.blocks = blocks
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
- * Empties the whole grid - only ever mutates the in-browser copy
1274
- * until Save is pressed, same as any other change here.
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 => block.date !== date)
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
  }