@opndev/tennis-match-center 0.0.3 → 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 +34 -0
- package/lib/auto-planner.mjs +1280 -601
- package/lib/components/planning-board.mjs +233 -8
- 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
|
@@ -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
|
}
|
|
@@ -10,7 +10,7 @@ import { competitionHue, divisionHues } from '../division-hues.mjs'
|
|
|
10
10
|
import { setNameText } from '../flags.mjs'
|
|
11
11
|
import { rowsForDate } from '../day-rows.mjs'
|
|
12
12
|
import { courtCanTake, toMinutes } from '../lighting.mjs'
|
|
13
|
-
import {
|
|
13
|
+
import { TennisDayGridElement } from './day-grid.mjs'
|
|
14
14
|
import { withBusyButton } from '../busy-button.mjs'
|
|
15
15
|
|
|
16
16
|
const KNOCKOUT_ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
|
|
@@ -468,10 +468,15 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
|
|
|
468
468
|
* Sent as a delete rather than as an empty save: which rows to drop
|
|
469
469
|
* follows from the event and the date, and a board that holds one
|
|
470
470
|
* day at a time could not send the rest of them to be replaced.
|
|
471
|
+
*
|
|
472
|
+
* A played match is not dropped. The database refuses to let the
|
|
473
|
+
* planner delete or move one, so the server keeps it - and a board
|
|
474
|
+
* that emptied its own copy anyway would show a morning that has
|
|
475
|
+
* been played as an empty morning until the next reload.
|
|
471
476
|
*/
|
|
472
477
|
clearAll() {
|
|
473
478
|
return this.clear(this.getConfig()['clear-api'], null, () => {
|
|
474
|
-
this.matches =
|
|
479
|
+
this.matches = this.matches.filter(match => match.played === true)
|
|
475
480
|
this.unscheduledByCompetition = new Map()
|
|
476
481
|
})
|
|
477
482
|
}
|
|
@@ -482,7 +487,9 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
|
|
|
482
487
|
}
|
|
483
488
|
|
|
484
489
|
return this.clear(`${this.getConfig()['day-api']}/${date}`, date, () => {
|
|
485
|
-
this.matches = this.matches.filter(match =>
|
|
490
|
+
this.matches = this.matches.filter(match =>
|
|
491
|
+
match.date !== date || match.played === true
|
|
492
|
+
)
|
|
486
493
|
})
|
|
487
494
|
}
|
|
488
495
|
|
|
@@ -948,9 +955,6 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
|
|
|
948
955
|
rows: this.rowsFor(date),
|
|
949
956
|
cellFor: (court, courtIndex, slot) => this.cellFor(court, date, slot),
|
|
950
957
|
compact: this.$('[data-narrow-empty]')?.checked ?? false,
|
|
951
|
-
// A scheduled cell carries four names and what is being played,
|
|
952
|
-
// where a planned one carries a division and a group.
|
|
953
|
-
scale: PX_PER_MINUTE * 2,
|
|
954
958
|
})
|
|
955
959
|
|
|
956
960
|
return grid
|
package/lib/court-bands.mjs
CHANGED
|
@@ -20,13 +20,18 @@
|
|
|
20
20
|
* division needs across every section, keyed by division id.
|
|
21
21
|
* @param {number[]} params.capacities How many courts each section can
|
|
22
22
|
* actually host (already filtered for lit/usable, per caller).
|
|
23
|
+
* @param {number} [params.rotation] Which day of the competition this
|
|
24
|
+
* is, counting from zero. Where a division's runs do not divide
|
|
25
|
+
* evenly over the sections, the extra ones move two sections on each
|
|
26
|
+
* day, the same way its groups do.
|
|
23
27
|
* @returns {{
|
|
24
28
|
* quotasBySection: Array<Map<string|number, number>>,
|
|
25
29
|
* bandsBySection: Array<Map<string|number, {start: number, end: number}>>
|
|
26
30
|
* }}
|
|
27
31
|
*/
|
|
28
|
-
export function computeSectionBands({ divisions, totals, capacities }) {
|
|
32
|
+
export function computeSectionBands({ divisions, totals, capacities, rotation = 0 }) {
|
|
29
33
|
const sectionCount = capacities.length
|
|
34
|
+
const evened = evenQuotas({ divisions, totals, capacities, rotation })
|
|
30
35
|
const quotasBySection = []
|
|
31
36
|
const bandsBySection = []
|
|
32
37
|
const granted = new Map(divisions.map(id => [id, 0]))
|
|
@@ -104,6 +109,15 @@ export function computeSectionBands({ divisions, totals, capacities }) {
|
|
|
104
109
|
room -= 1
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
// Where every division can be spread as evenly as its runs allow
|
|
113
|
+
// and the sections can hold it, that is the answer, whatever the
|
|
114
|
+
// capacity-driven shares above came to.
|
|
115
|
+
if (evened) {
|
|
116
|
+
for (const id of divisions) {
|
|
117
|
+
quotas.set(id, evened[section].get(id))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
// The band a division sits in, as a fraction of the section's own
|
|
108
122
|
// courts, straight from what it was just given. Divisions come in
|
|
109
123
|
// priority order, so the strongest field takes the lowest courts
|
|
@@ -129,6 +143,71 @@ export function computeSectionBands({ divisions, totals, capacities }) {
|
|
|
129
143
|
return { quotasBySection, bandsBySection }
|
|
130
144
|
}
|
|
131
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Each division's runs spread as evenly over the sections as they go.
|
|
148
|
+
*
|
|
149
|
+
* Every division takes the whole number of runs that divides evenly in
|
|
150
|
+
* each section, and whatever is left over goes one to a section, to the
|
|
151
|
+
* sections with the most room, so the extra run of one division sits
|
|
152
|
+
* where another's does not and the sections fill alike. Ties go to the
|
|
153
|
+
* earliest section, turned two sections on for each day of rotation:
|
|
154
|
+
* the group that had an extra run in a section yesterday is the one a
|
|
155
|
+
* rotation sends to the section two on today.
|
|
156
|
+
*
|
|
157
|
+
* Only when the sections can hold it. If they cannot, the caller's own
|
|
158
|
+
* capacity-driven shares are all there is, and this says so with null.
|
|
159
|
+
*
|
|
160
|
+
* @returns {?Array<Map<string|number, number>>} quotas by section
|
|
161
|
+
*/
|
|
162
|
+
function evenQuotas({ divisions, totals, capacities, rotation }) {
|
|
163
|
+
const sections = capacities.length
|
|
164
|
+
|
|
165
|
+
if (sections === 0) {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const load = new Array(sections).fill(0)
|
|
170
|
+
const quotas = capacities.map(() => new Map())
|
|
171
|
+
|
|
172
|
+
for (const id of divisions) {
|
|
173
|
+
const base = Math.floor((totals.get(id) ?? 0) / sections)
|
|
174
|
+
|
|
175
|
+
for (let section = 0; section < sections; section++) {
|
|
176
|
+
quotas[section].set(id, base)
|
|
177
|
+
load[section] += base
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Earliest first, turned by the day: [0, 1, 2], then [2, 0, 1].
|
|
182
|
+
const turn = (2 * rotation) % sections
|
|
183
|
+
const order = Array.from({ length: sections }, (_, i) => (i + turn) % sections)
|
|
184
|
+
|
|
185
|
+
for (const id of divisions) {
|
|
186
|
+
const extra = (totals.get(id) ?? 0) % sections
|
|
187
|
+
const given = new Set()
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < extra; i++) {
|
|
190
|
+
let best = -1
|
|
191
|
+
|
|
192
|
+
for (const section of order) {
|
|
193
|
+
if (given.has(section)) {
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (best === -1 || capacities[section] - load[section] > capacities[best] - load[best]) {
|
|
198
|
+
best = section
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
given.add(best)
|
|
203
|
+
load[best] += 1
|
|
204
|
+
quotas[best].set(id, quotas[best].get(id) + 1)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return load.every((runs, section) => runs <= capacities[section]) ? quotas : null
|
|
209
|
+
}
|
|
210
|
+
|
|
132
211
|
/**
|
|
133
212
|
* How far a court (as a fraction of its list) sits from a division's
|
|
134
213
|
* band. Zero inside the band; a band is an interval, not a point, so a
|
|
@@ -211,6 +211,6 @@ export function fourTeamUnitSizes(count, { dense = false } = {}) {
|
|
|
211
211
|
* @param {Array<{id: number, teamIds: number[]}>} groups
|
|
212
212
|
* @returns {Array<Array<{groupId: number, index: number}>>} slot by court
|
|
213
213
|
*/
|
|
214
|
-
export function fourTeamUnitLayout(groups) {
|
|
215
|
-
return unitLayout(groups, { template: FOUR_TEAM_TEMPLATE, matchesFor: fourTeamMatches })
|
|
214
|
+
export function fourTeamUnitLayout(groups, done = null) {
|
|
215
|
+
return unitLayout(groups, { template: FOUR_TEAM_TEMPLATE, matchesFor: fourTeamMatches, done })
|
|
216
216
|
}
|
package/lib/index.mjs
CHANGED
package/lib/knockout-draw.mjs
CHANGED
|
@@ -58,6 +58,7 @@ export class KnockoutDraw {
|
|
|
58
58
|
this.lastCourts = null
|
|
59
59
|
this.lastParts = 1
|
|
60
60
|
this.freeFrom = -Infinity
|
|
61
|
+
this.freeDate = null
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/** Whether every round of the draw has been planned. */
|
|
@@ -108,11 +109,22 @@ export class KnockoutDraw {
|
|
|
108
109
|
* Whether this draw may go on in this slot: it has a round left and
|
|
109
110
|
* has had whatever rest it is owed.
|
|
110
111
|
*
|
|
112
|
+
* Rest is owed within a day and a night is more of it than any rule
|
|
113
|
+
* asks, so a draw arriving on a new date is ready from its first
|
|
114
|
+
* slot. The slot index it was last free from means nothing on
|
|
115
|
+
* another day's list, which is what used to lock a draw that could
|
|
116
|
+
* not finish out of every day after it.
|
|
117
|
+
*
|
|
111
118
|
* @param {number} slotIndex
|
|
119
|
+
* @param {?string} [date]
|
|
112
120
|
* @returns {boolean}
|
|
113
121
|
*/
|
|
114
|
-
readyAt(slotIndex) {
|
|
115
|
-
|
|
122
|
+
readyAt(slotIndex, date = null) {
|
|
123
|
+
if (this.done) {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return date !== this.freeDate || slotIndex >= this.freeFrom
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
/**
|
|
@@ -123,19 +135,31 @@ export class KnockoutDraw {
|
|
|
123
135
|
* narrow it leaves the parts; quarters and eighths need parts that
|
|
124
136
|
* still hold matches of their own.
|
|
125
137
|
*
|
|
138
|
+
* A round does not have to be a power of two. Three of a round of
|
|
139
|
+
* eight played and five left is the same thing as a bracket with
|
|
140
|
+
* byes, and five still halves - 3 then 2 - which is what pays for
|
|
141
|
+
* the rest. A cut that would leave a part with nothing in it is not
|
|
142
|
+
* offered.
|
|
143
|
+
*
|
|
126
144
|
* @param {boolean} split Whether cutting is allowed at all.
|
|
127
145
|
* @returns {Array<{parts: number, count: number}>} Courts needed
|
|
128
|
-
* (count) for
|
|
146
|
+
* (count) for the widest part.
|
|
129
147
|
*/
|
|
130
148
|
cuts(split) {
|
|
131
149
|
const options = []
|
|
132
150
|
|
|
133
151
|
for (const parts of SPLIT_SIZES) {
|
|
134
|
-
if (parts > 1 &&
|
|
152
|
+
if (parts > 1 && !split) {
|
|
135
153
|
continue
|
|
136
154
|
}
|
|
137
155
|
|
|
138
|
-
const count = parts === 1 ? this.width : this.width / parts
|
|
156
|
+
const count = parts === 1 ? this.width : Math.ceil(this.width / parts)
|
|
157
|
+
|
|
158
|
+
// Every part has to hold something: a round of five in four
|
|
159
|
+
// parts is three parts and an empty one.
|
|
160
|
+
if (parts > 1 && count * (parts - 1) >= this.width) {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
139
163
|
|
|
140
164
|
if (parts > 2 && count < MIN_ROUND_PART) {
|
|
141
165
|
continue
|
|
@@ -190,9 +214,11 @@ export class KnockoutDraw {
|
|
|
190
214
|
* @param {number} params.parts The cut they were booked in.
|
|
191
215
|
* @param {number} params.slotIndex Where the cut started.
|
|
192
216
|
* @param {number} params.restSlots Slots owed after a whole round.
|
|
217
|
+
* @param {?string} [params.date] The day that slot index belongs to.
|
|
193
218
|
*/
|
|
194
|
-
book({ count, parts, slotIndex, restSlots }) {
|
|
219
|
+
book({ count, parts, slotIndex, restSlots, date = null }) {
|
|
195
220
|
this.placed += count
|
|
221
|
+
this.freeDate = date
|
|
196
222
|
|
|
197
223
|
if (this.placedInRound + count < this.width) {
|
|
198
224
|
this.placedInRound += count
|
package/lib/knockout-planner.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { MATCH_MINUTES, REST_MINUTES } from './match-time.mjs'
|
|
6
6
|
import { SPLIT_SIZES } from './knockout-draw.mjs'
|
|
7
|
-
import { activePhase, activeSession } from './sessions.mjs'
|
|
7
|
+
import { activePhase, activeRounds, activeSession } from './sessions.mjs'
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -242,7 +242,7 @@ function placeRound(item, held, day, where) {
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
draw.book({ count: placed, parts, slotIndex, restSlots })
|
|
245
|
+
draw.book({ count: placed, parts, slotIndex, restSlots, date: day.date })
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/**
|
|
@@ -327,8 +327,11 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
|
|
|
327
327
|
const ownSession = activeSession(ownSessions, date)
|
|
328
328
|
const restSlots = restSlotsFor(ownSession?.playtime ?? MATCH_MINUTES)
|
|
329
329
|
|
|
330
|
+
// A slot with no phase of its own belongs to a replan, which owns
|
|
331
|
+
// the whole day whatever the session had meant it for.
|
|
330
332
|
const firstSlot = own.findIndex(slot =>
|
|
331
|
-
|
|
333
|
+
slot.phase === null
|
|
334
|
+
|| (slot.phase ?? activePhase(ownSessions, date, slot.key)) === 'knockout'
|
|
332
335
|
)
|
|
333
336
|
|
|
334
337
|
if (firstSlot === -1) {
|
|
@@ -349,6 +352,7 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
|
|
|
349
352
|
|
|
350
353
|
for (let slotIndex = firstSlot; slotIndex < own.length; slotIndex++) {
|
|
351
354
|
const slot = own[slotIndex]
|
|
355
|
+
const onRounds = activeRounds(ownSessions, date, slot.key)
|
|
352
356
|
|
|
353
357
|
/** The lane's courts with nothing on them in this slot. */
|
|
354
358
|
function free() {
|
|
@@ -360,7 +364,11 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
|
|
|
360
364
|
}
|
|
361
365
|
|
|
362
366
|
let ready = units
|
|
363
|
-
.filter(draw => draw.readyAt(slotIndex))
|
|
367
|
+
.filter(draw => draw.readyAt(slotIndex, date))
|
|
368
|
+
// A session may say which rounds are on. Nothing else opens
|
|
369
|
+
// until one does, and a draw whose turn has not come waits
|
|
370
|
+
// for the day it was given rather than running ahead.
|
|
371
|
+
.filter(draw => onRounds == null || onRounds.includes(draw.round?.round))
|
|
364
372
|
.sort((a, b) => (b.width - a.width) || (b.level - a.level))
|
|
365
373
|
|
|
366
374
|
if (ready.length === 0) {
|