@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.
@@ -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
  }
@@ -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 { PX_PER_MINUTE, TennisDayGridElement } from './day-grid.mjs'
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 => match.date !== date)
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
@@ -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
@@ -2,6 +2,6 @@
2
2
  //
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
- const VERSION = "0.0.3";export { VERSION }
5
+ const VERSION = "0.0.4";export { VERSION }
6
6
  export { autoFillBracket } from './bracket-autofill.mjs'
7
7
 
@@ -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
- return !this.done && slotIndex >= this.freeFrom
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 each number of parts.
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 && (!split || this.width % parts !== 0)) {
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
@@ -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
- (slot.phase ?? activePhase(ownSessions, date, slot.key)) === 'knockout'
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) {