@opndev/tennis-match-center 0.0.4 → 0.0.6
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 +38 -0
- package/lib/auto-planner.mjs +91 -32
- package/lib/bracket-autofill.mjs +67 -13
- package/lib/busy-button.mjs +1 -1
- package/lib/components/bracket-board.mjs +5 -46
- package/lib/components/planning-board.mjs +39 -0
- package/lib/components/roster-board.mjs +21 -15
- package/lib/components/schedule-board.mjs +1 -0
- package/lib/court-runs.mjs +7 -1
- package/lib/day-rows.mjs +13 -3
- package/lib/four-team-planner.mjs +2 -1
- package/lib/index.mjs +1 -1
- package/lib/knockout-draw.mjs +4 -0
- package/lib/knockout-planner.mjs +5 -1
- package/lib/lighting.mjs +43 -1
- package/lib/occupancy.mjs +5 -0
- package/lib/paired-court-planner.mjs +8 -5
- package/lib/roster-seeding.mjs +2 -10
- package/package.json +1 -1
package/Changes
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
Revision history for @opndev/tennis-match-center
|
|
2
2
|
|
|
3
|
+
0.0.6 2026-11-06 17:20:26Z
|
|
4
|
+
|
|
5
|
+
* The roster board says when a group plays, above its name, as a
|
|
6
|
+
window such as `Mon 10 Nov, 13:00-16:30`: the first start to the end
|
|
7
|
+
of the last one, from an `ends` list next to `starts`. Without
|
|
8
|
+
`ends` it reads as before.
|
|
9
|
+
|
|
10
|
+
* Groups are named 1, 2, 3, ... instead of A, B, C, ... Z, AA. Bracket
|
|
11
|
+
seeding sorts group names numerically, so 10 follows 9.
|
|
12
|
+
|
|
13
|
+
* A court override can carry `available_until`: the court takes no
|
|
14
|
+
play that ends after it that day. It works on any court, pro or not.
|
|
15
|
+
On a pro court the opening time still only lets regular play in.
|
|
16
|
+
|
|
17
|
+
* The Planning board's right-click menu and legend leave out a
|
|
18
|
+
competition that is not available for planning.
|
|
19
|
+
|
|
20
|
+
* A day can have its own dark from and light buffer. The boards and
|
|
21
|
+
the planner take a `day_lighting` list of `{date, dark_from,
|
|
22
|
+
light_buffer}`, and a day without an entry, or with either left
|
|
23
|
+
empty, keeps the event's.
|
|
24
|
+
|
|
25
|
+
* The Planning board takes `court_overrides` and `day_lighting` from
|
|
26
|
+
the competitions response as well as from the shell, so Auto-fill
|
|
27
|
+
plans against what is saved now rather than what the page loaded
|
|
28
|
+
with. The board also listens for a `planning-settings-saved` event
|
|
29
|
+
on the window and draws again when it arrives; the cells on it are
|
|
30
|
+
not touched.
|
|
31
|
+
|
|
32
|
+
0.0.5 2026-10-12 14:29:04Z
|
|
33
|
+
|
|
34
|
+
* After dark a knockout round takes the lowest lit courts. Courts shut
|
|
35
|
+
for the dark counted as busy, which drew the round up the row to sit
|
|
36
|
+
beside them.
|
|
37
|
+
|
|
38
|
+
* A round is never cut into uneven quarters or eighths. Ten matches
|
|
39
|
+
halve 5/5 and no longer go 3/3/3/1.
|
|
40
|
+
|
|
3
41
|
0.0.4 2026-10-10 14:39:58Z
|
|
4
42
|
|
|
5
43
|
* Auto-fill only adds. The Planning board hands the planner the cells
|
package/lib/auto-planner.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { Plan } from './plan.mjs'
|
|
|
19
19
|
import { PlanningDay } from './planning-day.mjs'
|
|
20
20
|
import { activePhase, activeSession } from './sessions.mjs'
|
|
21
21
|
import { MATCH_MINUTES } from './match-time.mjs'
|
|
22
|
+
import { lightingForDate, relightSlots } from './lighting.mjs'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Every day the competitions say they play on: the dates of their
|
|
@@ -197,6 +198,7 @@ class AutoPlanner {
|
|
|
197
198
|
darkFrom = null,
|
|
198
199
|
dayEnd = '24:00',
|
|
199
200
|
lightBuffer = undefined,
|
|
201
|
+
dayLighting = [],
|
|
200
202
|
groupSlotsFromSessions = false,
|
|
201
203
|
splitRounds = false,
|
|
202
204
|
sharing = false,
|
|
@@ -307,6 +309,7 @@ class AutoPlanner {
|
|
|
307
309
|
darkFrom,
|
|
308
310
|
dayEnd,
|
|
309
311
|
lightBuffer,
|
|
312
|
+
dayLighting,
|
|
310
313
|
groupSlotsFromSessions,
|
|
311
314
|
splitRounds,
|
|
312
315
|
sharing,
|
|
@@ -433,10 +436,10 @@ class AutoPlanner {
|
|
|
433
436
|
overridesByDate.set(override.date, new Map())
|
|
434
437
|
}
|
|
435
438
|
|
|
436
|
-
overridesByDate.get(override.date).set(
|
|
437
|
-
override.
|
|
438
|
-
override.
|
|
439
|
-
)
|
|
439
|
+
overridesByDate.get(override.date).set(override.court_id, {
|
|
440
|
+
from: override.available_from ? timeToMinutes(override.available_from) : null,
|
|
441
|
+
until: override.available_until ? timeToMinutes(override.available_until) : null,
|
|
442
|
+
})
|
|
440
443
|
}
|
|
441
444
|
|
|
442
445
|
const venue = { overridesByDate, openCourts, proCourts, activeCourts }
|
|
@@ -467,14 +470,12 @@ class AutoPlanner {
|
|
|
467
470
|
courts,
|
|
468
471
|
registrationClosed,
|
|
469
472
|
alwaysByeShortGroups,
|
|
470
|
-
lightBuffer,
|
|
471
473
|
groupSlotsFromSessions,
|
|
472
474
|
splitRounds,
|
|
473
475
|
standing,
|
|
474
476
|
days,
|
|
475
477
|
competitions,
|
|
476
478
|
slots,
|
|
477
|
-
darkFromMinutes,
|
|
478
479
|
plan,
|
|
479
480
|
blocks,
|
|
480
481
|
remainingGroupByDate,
|
|
@@ -514,23 +515,23 @@ class AutoPlanner {
|
|
|
514
515
|
const slotsByCompetition = new Map(
|
|
515
516
|
competitions.map(competition => [
|
|
516
517
|
competition.id,
|
|
517
|
-
competition.slots
|
|
518
|
+
this.litFor(competition.slots
|
|
518
519
|
? withMinutes(competition.slots)
|
|
519
520
|
: ((groupSlotsFromSessions || slots.length === 0)
|
|
520
521
|
? this.dayFor(competition, date)
|
|
521
|
-
: slots),
|
|
522
|
+
: slots), date),
|
|
522
523
|
])
|
|
523
524
|
)
|
|
524
525
|
|
|
525
526
|
const day = new PlanningDay({
|
|
526
527
|
date,
|
|
527
|
-
proCourts,
|
|
528
|
+
proCourts: this.proCourtsFor(date),
|
|
528
529
|
openCourts: this.openCourtsFor(date),
|
|
529
530
|
slotsByCompetition,
|
|
530
531
|
occupancy: taken,
|
|
531
532
|
plan,
|
|
532
|
-
darkFromMinutes,
|
|
533
|
-
lightBufferMinutes:
|
|
533
|
+
darkFromMinutes: this.darkFromFor(date),
|
|
534
|
+
lightBufferMinutes: this.lightBufferFor(date),
|
|
534
535
|
})
|
|
535
536
|
|
|
536
537
|
if (this.sharing) {
|
|
@@ -554,7 +555,7 @@ class AutoPlanner {
|
|
|
554
555
|
|
|
555
556
|
const last = own[Math.min(slotIndex + runLength - 1, own.length - 1)]
|
|
556
557
|
const needsLight = runNeedsLight(
|
|
557
|
-
own, slotIndex, runLength,
|
|
558
|
+
own, slotIndex, runLength, this.darkFromFor(date)
|
|
558
559
|
)
|
|
559
560
|
|
|
560
561
|
timeline.push({
|
|
@@ -646,7 +647,7 @@ class AutoPlanner {
|
|
|
646
647
|
}
|
|
647
648
|
|
|
648
649
|
const laneCourts = entry.group.is_pro === true
|
|
649
|
-
?
|
|
650
|
+
? this.proCourtsFor(date)
|
|
650
651
|
: this.openCourtsFor(date)
|
|
651
652
|
let landed = false
|
|
652
653
|
|
|
@@ -1240,12 +1241,12 @@ class AutoPlanner {
|
|
|
1240
1241
|
* competitions placed exactly half of theirs.
|
|
1241
1242
|
*/
|
|
1242
1243
|
groupDatesFor(competition) {
|
|
1243
|
-
const { sortedSessions,
|
|
1244
|
+
const { sortedSessions, dayEndMinutes } = this.canvas
|
|
1244
1245
|
|
|
1245
1246
|
const sessions = sortedSessions.get(competition.id) ?? []
|
|
1246
1247
|
|
|
1247
1248
|
return (competition.group_dates ?? []).filter(date => {
|
|
1248
|
-
const own = slotsForDate(sessions, date,
|
|
1249
|
+
const own = slotsForDate(sessions, date, this.darkFromFor(date), dayEndMinutes)
|
|
1249
1250
|
|
|
1250
1251
|
return own.length > 0 && own[0].phase === 'group'
|
|
1251
1252
|
})
|
|
@@ -1327,12 +1328,10 @@ class AutoPlanner {
|
|
|
1327
1328
|
* phase is on.
|
|
1328
1329
|
*/
|
|
1329
1330
|
dayFor(competition, on) {
|
|
1330
|
-
const {
|
|
1331
|
-
sortedSessions, darkFromMinutes, dayEndMinutes,
|
|
1332
|
-
} = this.canvas
|
|
1331
|
+
const { sortedSessions, dayEndMinutes } = this.canvas
|
|
1333
1332
|
const own = sortedSessions.get(competition.id) ?? []
|
|
1334
1333
|
const fromSessions = slotsForDate(
|
|
1335
|
-
own, on,
|
|
1334
|
+
own, on, this.darkFromFor(on), dayEndMinutes, this.anchorFor(on)
|
|
1336
1335
|
)
|
|
1337
1336
|
|
|
1338
1337
|
return fromSessions
|
|
@@ -1340,19 +1339,54 @@ class AutoPlanner {
|
|
|
1340
1339
|
|
|
1341
1340
|
slotsFor(competitionId, date) {
|
|
1342
1341
|
const {
|
|
1343
|
-
groupSlotsFromSessions, slots, sortedSessions,
|
|
1342
|
+
groupSlotsFromSessions, slots, sortedSessions, dayEndMinutes,
|
|
1344
1343
|
} = this.canvas
|
|
1345
1344
|
|
|
1346
1345
|
if (!groupSlotsFromSessions && slots.length > 0) {
|
|
1347
|
-
return slots
|
|
1346
|
+
return this.litFor(slots, date)
|
|
1348
1347
|
}
|
|
1349
1348
|
|
|
1350
1349
|
return slotsForDate(
|
|
1351
1350
|
sortedSessions.get(Number(competitionId)) ?? [],
|
|
1352
|
-
date,
|
|
1351
|
+
date, this.darkFromFor(date), dayEndMinutes, this.anchorFor(date)
|
|
1352
|
+
)
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* The lighting on this date: the day's own dark from and light
|
|
1357
|
+
* buffer where it has them, the event's otherwise.
|
|
1358
|
+
*
|
|
1359
|
+
* @param {string} date
|
|
1360
|
+
* @returns {{darkFromMinutes: ?number, lightBufferMinutes: (number|undefined), ownDarkFrom: boolean}}
|
|
1361
|
+
*/
|
|
1362
|
+
lightingFor(date) {
|
|
1363
|
+
return lightingForDate(
|
|
1364
|
+
this.dayLighting, date, this.darkFromMinutes, this.lightBuffer
|
|
1353
1365
|
)
|
|
1354
1366
|
}
|
|
1355
1367
|
|
|
1368
|
+
darkFromFor(date) {
|
|
1369
|
+
return this.lightingFor(date).darkFromMinutes
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
lightBufferFor(date) {
|
|
1373
|
+
return this.lightingFor(date).lightBufferMinutes
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Slots whose lit_required reads off this date's own dark from.
|
|
1378
|
+
* Left as they are when the day has none.
|
|
1379
|
+
*
|
|
1380
|
+
* @param {Array} slots
|
|
1381
|
+
* @param {string} date
|
|
1382
|
+
* @returns {Array}
|
|
1383
|
+
*/
|
|
1384
|
+
litFor(slots, date) {
|
|
1385
|
+
const { ownDarkFrom, darkFromMinutes } = this.lightingFor(date)
|
|
1386
|
+
|
|
1387
|
+
return ownDarkFrom ? relightSlots(slots, darkFromMinutes) : slots
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1356
1390
|
/**
|
|
1357
1391
|
* The hour the day's first competition opens, which every
|
|
1358
1392
|
* competition's slots are measured from.
|
|
@@ -1394,7 +1428,7 @@ class AutoPlanner {
|
|
|
1394
1428
|
* so courtUsableAt can enforce it without needing to know the date.
|
|
1395
1429
|
*/
|
|
1396
1430
|
openCourtsFor(date) {
|
|
1397
|
-
const { overridesByDate, openCourts,
|
|
1431
|
+
const { overridesByDate, openCourts, activeCourts } = this.venue
|
|
1398
1432
|
|
|
1399
1433
|
const overrides = overridesByDate.get(date)
|
|
1400
1434
|
|
|
@@ -1402,19 +1436,44 @@ class AutoPlanner {
|
|
|
1402
1436
|
return openCourts
|
|
1403
1437
|
}
|
|
1404
1438
|
|
|
1405
|
-
const
|
|
1406
|
-
.filter(court => overrides.has(court.id))
|
|
1407
|
-
.map(court => [court.id, {
|
|
1408
|
-
...court, availableFromMinutes: overrides.get(court.id),
|
|
1409
|
-
}]))
|
|
1439
|
+
const shaped = court => this.withOverride(court, overrides.get(court.id))
|
|
1410
1440
|
|
|
1411
|
-
if (
|
|
1441
|
+
if (!activeCourts.some(court => overrides.has(court.id))) {
|
|
1412
1442
|
return openCourts
|
|
1413
1443
|
}
|
|
1414
1444
|
|
|
1415
1445
|
return activeCourts
|
|
1416
|
-
.filter(court => !court.pro ||
|
|
1417
|
-
.map(
|
|
1446
|
+
.filter(court => !court.pro || overrides.has(court.id))
|
|
1447
|
+
.map(shaped)
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* The pro courts for this date. Only an override's cutoff applies
|
|
1452
|
+
* here: its opening time is about letting regular play in.
|
|
1453
|
+
*
|
|
1454
|
+
* @param {string} date
|
|
1455
|
+
* @returns {Array}
|
|
1456
|
+
*/
|
|
1457
|
+
proCourtsFor(date) {
|
|
1458
|
+
const overrides = this.venue.overridesByDate.get(date)
|
|
1459
|
+
|
|
1460
|
+
return this.venue.proCourts.map(court => this.withOverride(
|
|
1461
|
+
court, overrides?.has(court.id)
|
|
1462
|
+
? { from: null, until: overrides.get(court.id).until }
|
|
1463
|
+
: null
|
|
1464
|
+
))
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
withOverride(court, override) {
|
|
1468
|
+
if (!override) {
|
|
1469
|
+
return court
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
return {
|
|
1473
|
+
...court,
|
|
1474
|
+
availableFromMinutes: override.from,
|
|
1475
|
+
availableUntilMinutes: override.until,
|
|
1476
|
+
}
|
|
1418
1477
|
}
|
|
1419
1478
|
|
|
1420
1479
|
/**
|
|
@@ -1584,7 +1643,7 @@ function runNeedsLight(own, slotIndex, runLength, darkFromMinutes) {
|
|
|
1584
1643
|
*
|
|
1585
1644
|
* @param {Array} entries
|
|
1586
1645
|
* @param {boolean} requireLit
|
|
1587
|
-
* @returns {Array<
|
|
1646
|
+
* @returns {Array<Array>} Pairs of start minute and its entries.
|
|
1588
1647
|
*/
|
|
1589
1648
|
function byStartTime(entries, requireLit) {
|
|
1590
1649
|
const byStart = new Map()
|
package/lib/bracket-autofill.mjs
CHANGED
|
@@ -53,7 +53,9 @@ export function autoFillBracket(seeds) {
|
|
|
53
53
|
qualifiers: pools.qualifiers,
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// matches() interleaves the sides. A bracket is the left side, then
|
|
57
|
+
// the right, so two neighbours feed the same next match.
|
|
58
|
+
rawMatches = [...bracket.matches('left'), ...bracket.matches('right')]
|
|
57
59
|
round = bracket.rounds()[0]
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -112,23 +114,56 @@ function buildMatches(round, firstRoundSlots) {
|
|
|
112
114
|
* own representatives are kept apart rather than merely ranked by
|
|
113
115
|
* overall strength - see <tennis-bracket-board>'s own copy of this
|
|
114
116
|
* doc comment for the full rationale.
|
|
117
|
+
*
|
|
118
|
+
* The lower tiers are rotated so a group's second place does not draw
|
|
119
|
+
* the side of the bracket its winner is on. How far depends on the
|
|
120
|
+
* draw, so it is the first rotation that leaves no group on one side,
|
|
121
|
+
* found by building the bracket.
|
|
115
122
|
*/
|
|
116
|
-
function poolsFromSeeds(seeds) {
|
|
117
|
-
const
|
|
118
|
-
const
|
|
123
|
+
export function poolsFromSeeds(seeds) {
|
|
124
|
+
const draw = Math.max(2, nextPowerOfTwo(seeds.length))
|
|
125
|
+
const tiers = tiersOf(seeds)
|
|
126
|
+
const longest = Math.max(...tiers.map(tier => tier.length))
|
|
127
|
+
const half = Math.floor(longest / 2)
|
|
119
128
|
|
|
120
|
-
|
|
129
|
+
if (draw < 8) {
|
|
130
|
+
return poolsFor(tiers, half)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const offsets = [half, ...Array.from({ length: longest }, (_, i) => i).filter(i => i !== half)]
|
|
121
134
|
|
|
122
|
-
|
|
123
|
-
const ordered = seeds
|
|
124
|
-
.filter(seed => seed.tier === tier)
|
|
125
|
-
.sort((a, b) => a.group_name.localeCompare(b.group_name))
|
|
126
|
-
.map(seed => String(seed.team_id))
|
|
135
|
+
let best = { offset: half, apart: Infinity }
|
|
127
136
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
for (const offset of offsets) {
|
|
138
|
+
const apart = groupsOnOneSide(seeds, tiers, draw, offset)
|
|
139
|
+
|
|
140
|
+
if (apart < best.apart) {
|
|
141
|
+
best = { offset, apart }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (apart === 0) {
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return poolsFor(tiers, best.offset)
|
|
150
|
+
}
|
|
131
151
|
|
|
152
|
+
function tiersOf(seeds) {
|
|
153
|
+
const tierNumbers = [...new Set(seeds.map(seed => seed.tier))].sort((a, b) => a - b)
|
|
154
|
+
|
|
155
|
+
return tierNumbers.map(tier => seeds
|
|
156
|
+
.filter(seed => seed.tier === tier)
|
|
157
|
+
.sort((a, b) => a.group_name.localeCompare(b.group_name, undefined, { numeric: true }))
|
|
158
|
+
.map(seed => String(seed.team_id)))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function poolsFor(tiers, offset) {
|
|
162
|
+
const poolNames = ['seeded', 'unseeded', 'wildcards', 'qualifiers']
|
|
163
|
+
const pools = { seeded: [], unseeded: [], wildcards: [], qualifiers: [] }
|
|
164
|
+
|
|
165
|
+
tiers.forEach((ordered, tierIndex) => {
|
|
166
|
+
const rotated = tierIndex === 0 ? ordered : rotate(ordered, offset % Math.max(ordered.length, 1))
|
|
132
167
|
const poolName = poolNames[tierIndex] ?? 'qualifiers'
|
|
133
168
|
|
|
134
169
|
pools[poolName].push(...rotated)
|
|
@@ -137,6 +172,25 @@ function poolsFromSeeds(seeds) {
|
|
|
137
172
|
return pools
|
|
138
173
|
}
|
|
139
174
|
|
|
175
|
+
/**
|
|
176
|
+
* How many groups have a lower tier on the side of the bracket their
|
|
177
|
+
* winner is on. Two of a group on one side meet before the Final.
|
|
178
|
+
*/
|
|
179
|
+
function groupsOnOneSide(seeds, tiers, draw, offset) {
|
|
180
|
+
const pools = poolsFor(tiers, offset)
|
|
181
|
+
const bracket = new SingleEliminationBracket({ draw, ...pools })
|
|
182
|
+
const side = new Map()
|
|
183
|
+
|
|
184
|
+
for (const [name, matches] of [['left', bracket.matches('left')], ['right', bracket.matches('right')]]) {
|
|
185
|
+
matches.forEach(match => match.split(' vs ').forEach(team => side.set(team, name)))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const group = new Map(seeds.map(seed => [String(seed.team_id), seed.group_name]))
|
|
189
|
+
const winners = new Map(tiers[0].map(team => [group.get(team), side.get(team)]))
|
|
190
|
+
|
|
191
|
+
return tiers.slice(1).flat().filter(team => side.get(team) === winners.get(group.get(team))).length
|
|
192
|
+
}
|
|
193
|
+
|
|
140
194
|
function rotate(array, offset) {
|
|
141
195
|
return [...array.slice(offset), ...array.slice(0, offset)]
|
|
142
196
|
}
|
package/lib/busy-button.mjs
CHANGED
|
@@ -18,7 +18,7 @@ const DEFAULT_LABELS = {
|
|
|
18
18
|
* second press cannot start one on top of the first.
|
|
19
19
|
*
|
|
20
20
|
* @param {?HTMLElement} button
|
|
21
|
-
* @param {()
|
|
21
|
+
* @param {function(): Promise<boolean>} run Resolves true where the work
|
|
22
22
|
* succeeded.
|
|
23
23
|
* @param {{busy: string, done: string, failed: string}} [labels] What
|
|
24
24
|
* the button says while it works, and afterwards.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { HTMLElementSugar } from '@skirbi/sugar'
|
|
6
6
|
import { SingleEliminationBracket } from '../bracket-single-elimination.mjs'
|
|
7
|
+
import { poolsFromSeeds } from '../bracket-autofill.mjs'
|
|
7
8
|
|
|
8
9
|
const ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
|
|
9
10
|
|
|
@@ -143,7 +144,9 @@ export class TennisBracketBoardElement extends HTMLElementSugar {
|
|
|
143
144
|
qualifiers: pools.qualifiers,
|
|
144
145
|
})
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
// matches() interleaves the sides. A bracket is the left side, then
|
|
148
|
+
// the right, so two neighbours feed the same next match.
|
|
149
|
+
rawMatches = [...bracket.matches('left'), ...bracket.matches('right')]
|
|
147
150
|
round = bracket.rounds()[0]
|
|
148
151
|
}
|
|
149
152
|
|
|
@@ -280,7 +283,7 @@ export class TennisBracketBoardElement extends HTMLElementSugar {
|
|
|
280
283
|
return
|
|
281
284
|
}
|
|
282
285
|
|
|
283
|
-
const firstRound = this.
|
|
286
|
+
const firstRound = this.round ?? firstRoundOf(this.matches) ?? this.firstRoundSlots[0]?.round
|
|
284
287
|
const roundNames = roundsFrom(firstRound ?? ROUND_ORDER.at(-1))
|
|
285
288
|
|
|
286
289
|
let count = slotCount
|
|
@@ -473,50 +476,6 @@ function tierLabel(tier) {
|
|
|
473
476
|
return { 1: 'Winner', 2: 'Runner-up' }[tier] ?? `${tier}th place`
|
|
474
477
|
}
|
|
475
478
|
|
|
476
|
-
/**
|
|
477
|
-
* Buckets seeds by advancement tier (1st of each group, 2nd of each
|
|
478
|
-
* group, ...) into SingleEliminationBracket's four pools, so a group's
|
|
479
|
-
* own representatives are kept apart rather than merely ranked by
|
|
480
|
-
* overall strength - the generic seeding algorithm has no notion of
|
|
481
|
-
* "these two came from the same source" on its own.
|
|
482
|
-
*
|
|
483
|
-
* Tier 1 -> seeded, in group order. Every later tier is rotated by
|
|
484
|
-
* half its own length before becoming the next pool, so
|
|
485
|
-
* pool[i] never comes from the same group as seeded[i]: with the
|
|
486
|
-
* algorithm's own "Seed k vs Unseeded k" round-1 pairing, that keeps a
|
|
487
|
-
* group's pair in different bracket positions from round 1 onward.
|
|
488
|
-
* When the tier count is a power of two this provably keeps a group's
|
|
489
|
-
* representatives in opposite halves of the draw (they can only meet
|
|
490
|
-
* in the Final) - see bracket-board.test for the worked proof. With an
|
|
491
|
-
* uneven group count or more than 4 tiers (this pool mechanism only
|
|
492
|
-
* has four) the guarantee is best-effort rather than proven.
|
|
493
|
-
*/
|
|
494
|
-
function poolsFromSeeds(seeds) {
|
|
495
|
-
const tierNumbers = [...new Set(seeds.map(seed => seed.tier))].sort((a, b) => a - b)
|
|
496
|
-
const poolNames = ['seeded', 'unseeded', 'wildcards', 'qualifiers']
|
|
497
|
-
|
|
498
|
-
const pools = { seeded: [], unseeded: [], wildcards: [], qualifiers: [] }
|
|
499
|
-
|
|
500
|
-
tierNumbers.forEach((tier, tierIndex) => {
|
|
501
|
-
const ordered = seeds
|
|
502
|
-
.filter(seed => seed.tier === tier)
|
|
503
|
-
.sort((a, b) => a.group_name.localeCompare(b.group_name))
|
|
504
|
-
.map(seed => String(seed.team_id))
|
|
505
|
-
|
|
506
|
-
const rotated = tierIndex === 0
|
|
507
|
-
? ordered
|
|
508
|
-
: rotate(ordered, Math.floor(ordered.length / 2))
|
|
509
|
-
|
|
510
|
-
// A 5th+ tier has nowhere to go - the algorithm only has four
|
|
511
|
-
// pools - so it folds into qualifiers rather than being dropped.
|
|
512
|
-
const poolName = poolNames[tierIndex] ?? 'qualifiers'
|
|
513
|
-
|
|
514
|
-
pools[poolName].push(...rotated)
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
return pools
|
|
518
|
-
}
|
|
519
|
-
|
|
520
479
|
function rotate(array, offset) {
|
|
521
480
|
return [...array.slice(offset), ...array.slice(0, offset)]
|
|
522
481
|
}
|
|
@@ -231,6 +231,8 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
231
231
|
event.preventDefault()
|
|
232
232
|
this.assignSelection(null)
|
|
233
233
|
})
|
|
234
|
+
|
|
235
|
+
window.addEventListener('planning-settings-saved', () => this.refreshSettings())
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
// The shell (courts/days/slots/the already-saved plan) is plain
|
|
@@ -285,6 +287,11 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
285
287
|
* half, needs re-fetching for that - the grid shell it's layered
|
|
286
288
|
* onto (courts, days, slots) does not change from a pairing edit.
|
|
287
289
|
*
|
|
290
|
+
* The response also carries the court overrides and the days' own
|
|
291
|
+
* lighting, which a director edits next to the board. They are taken
|
|
292
|
+
* the same way, so a plan is made against what is saved now. A
|
|
293
|
+
* response without them leaves what the board has.
|
|
294
|
+
*
|
|
288
295
|
* @returns {Promise<boolean>} false if a newer fetch has since
|
|
289
296
|
* started and this one's result must be discarded.
|
|
290
297
|
*/
|
|
@@ -296,11 +303,36 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
296
303
|
}
|
|
297
304
|
|
|
298
305
|
this.state.competitions = data.competitions
|
|
306
|
+
|
|
307
|
+
if (data.court_overrides !== undefined) {
|
|
308
|
+
this.state.court_overrides = data.court_overrides
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (data.day_lighting !== undefined) {
|
|
312
|
+
this.state.day_lighting = data.day_lighting
|
|
313
|
+
}
|
|
314
|
+
|
|
299
315
|
this._divisionHues = null
|
|
300
316
|
|
|
301
317
|
return true
|
|
302
318
|
}
|
|
303
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Draws the board again with what is saved now. Called when the
|
|
322
|
+
* settings next to it were saved: the board is not part of that
|
|
323
|
+
* form, and would otherwise show the old lighting until a reload.
|
|
324
|
+
* The cells on the board are not touched.
|
|
325
|
+
*/
|
|
326
|
+
async refreshSettings() {
|
|
327
|
+
if (!this.isConnected) {
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (await this.fetchCompetitions()) {
|
|
332
|
+
this.render()
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
304
336
|
/**
|
|
305
337
|
* Shared double-connect/stale-request guard: only the result of the
|
|
306
338
|
* most recently started fetch (shell or competitions, whichever)
|
|
@@ -323,11 +355,16 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
323
355
|
* Competitions relevant to one day, each tagged with the phase that
|
|
324
356
|
* applies on that day - group_dates and knockout_dates for the same
|
|
325
357
|
* competition never overlap, so at most one entry per competition.
|
|
358
|
+
* A competition not available for planning has none.
|
|
326
359
|
*/
|
|
327
360
|
competitionsForDate(date) {
|
|
328
361
|
const entries = []
|
|
329
362
|
|
|
330
363
|
for (const competition of this.state.competitions) {
|
|
364
|
+
if (competition.available_for_planning === false) {
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
|
|
331
368
|
if (competition.group_dates.includes(date)) {
|
|
332
369
|
entries.push({ competition, phase: 'group' })
|
|
333
370
|
}
|
|
@@ -1310,6 +1347,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1310
1347
|
alwaysByeShortGroups: this.alwaysByeShortGroups(),
|
|
1311
1348
|
relaxed: this.relaxed(),
|
|
1312
1349
|
courtOverrides: this.state.court_overrides ?? [],
|
|
1350
|
+
dayLighting: this.state.day_lighting ?? [],
|
|
1313
1351
|
|
|
1314
1352
|
// Group play follows each competition's own session, so a
|
|
1315
1353
|
// competition starting at 09:00 starts at 09:00. The list is
|
|
@@ -1778,6 +1816,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
|
|
|
1778
1816
|
fallbackSlots: this.state.slots ?? [],
|
|
1779
1817
|
darkFromMinutes: toMinutes(this.getConfig()['dark-from']),
|
|
1780
1818
|
lightBufferMinutes: this.lightBuffer() ?? 0,
|
|
1819
|
+
dayLighting: this.state.day_lighting ?? [],
|
|
1781
1820
|
})
|
|
1782
1821
|
}
|
|
1783
1822
|
|
|
@@ -24,15 +24,21 @@ function dayOf(iso) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* When a group
|
|
28
|
-
*
|
|
27
|
+
* When a group plays, as far as the planning knows it: from the first
|
|
28
|
+
* match start to the end of the last one, for each day it plays. A day
|
|
29
|
+
* without an end reads as just its start.
|
|
29
30
|
*
|
|
30
31
|
* @param {Array<string>} starts - ISO timestamps, one per day
|
|
32
|
+
* @param {Array<string>} [ends] - ISO timestamps, one per day
|
|
31
33
|
* @returns {string}
|
|
32
34
|
*/
|
|
33
|
-
export function startsLine(starts) {
|
|
35
|
+
export function startsLine(starts, ends = []) {
|
|
34
36
|
return starts
|
|
35
|
-
.map(iso =>
|
|
37
|
+
.map((iso, day) => {
|
|
38
|
+
const end = ends[day] ? `-${ends[day].slice(11, 16)}` : ''
|
|
39
|
+
|
|
40
|
+
return `${dayOf(iso)}, ${iso.slice(11, 16)}${end}`
|
|
41
|
+
})
|
|
36
42
|
.join(' / ')
|
|
37
43
|
}
|
|
38
44
|
|
|
@@ -204,11 +210,11 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
|
|
|
204
210
|
.map(id => this.saved.teams.find(team => team.id === id))
|
|
205
211
|
.filter(Boolean)
|
|
206
212
|
|
|
207
|
-
columns.appendChild(this.columnFor(group.name, teams, group.starts))
|
|
213
|
+
columns.appendChild(this.columnFor(group.name, teams, group.starts, group.ends))
|
|
208
214
|
}
|
|
209
215
|
}
|
|
210
216
|
|
|
211
|
-
columnFor(currentGroupName, teams, starts = []) {
|
|
217
|
+
columnFor(currentGroupName, teams, starts = [], ends = []) {
|
|
212
218
|
const column = document.createElement('div')
|
|
213
219
|
|
|
214
220
|
column.dataset.group = currentGroupName ?? ''
|
|
@@ -234,6 +240,15 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
|
|
|
234
240
|
}
|
|
235
241
|
})
|
|
236
242
|
|
|
243
|
+
if (starts?.length) {
|
|
244
|
+
const when = document.createElement('p')
|
|
245
|
+
|
|
246
|
+
when.dataset.planned = ''
|
|
247
|
+
when.title = 'When the group plays, per day'
|
|
248
|
+
when.textContent = startsLine(starts, ends)
|
|
249
|
+
column.appendChild(when)
|
|
250
|
+
}
|
|
251
|
+
|
|
237
252
|
const heading = document.createElement('h3')
|
|
238
253
|
heading.textContent = currentGroupName ? `Group ${currentGroupName}` : 'Unassigned'
|
|
239
254
|
column.appendChild(heading)
|
|
@@ -243,15 +258,6 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
|
|
|
243
258
|
count.textContent = String(teams.length)
|
|
244
259
|
heading.appendChild(count)
|
|
245
260
|
|
|
246
|
-
if (starts?.length) {
|
|
247
|
-
const when = document.createElement('p')
|
|
248
|
-
|
|
249
|
-
when.dataset.planned = ''
|
|
250
|
-
when.title = 'First match of the group, per day'
|
|
251
|
-
when.textContent = startsLine(starts)
|
|
252
|
-
column.appendChild(when)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
261
|
const list = document.createElement('div')
|
|
256
262
|
list.dataset.list = ''
|
|
257
263
|
column.appendChild(list)
|
|
@@ -728,6 +728,7 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
|
|
|
728
728
|
cells: this.allCellsWithCompetition(),
|
|
729
729
|
darkFromMinutes: toMinutes(this.getConfig()['dark-from']),
|
|
730
730
|
lightBufferMinutes: this.lightBuffer() ?? 0,
|
|
731
|
+
dayLighting: this.state.day_lighting ?? [],
|
|
731
732
|
})
|
|
732
733
|
}
|
|
733
734
|
|
package/lib/court-runs.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
/**
|
|
15
15
|
* Whether a court may be played on in this slot at all.
|
|
16
16
|
*
|
|
17
|
-
* @param {{lit: boolean, availableFromMinutes: ?number}} court
|
|
17
|
+
* @param {{lit: boolean, availableFromMinutes: ?number, availableUntilMinutes: ?number}} court
|
|
18
18
|
* @param {{lit_required: boolean, start_minutes: number}} slot
|
|
19
19
|
* @returns {boolean}
|
|
20
20
|
*/
|
|
@@ -32,6 +32,12 @@ export function courtUsableAt(court, slot) {
|
|
|
32
32
|
return false
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
const closesAt = court.availableUntilMinutes
|
|
36
|
+
|
|
37
|
+
if (closesAt != null && slot.end_minutes > closesAt) {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
return true
|
|
36
42
|
}
|
|
37
43
|
|
package/lib/day-rows.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
4
|
|
|
5
5
|
import { slotsForDate } from './auto-planner.mjs'
|
|
6
|
-
import { litRequired } from './lighting.mjs'
|
|
6
|
+
import { lightingForDate, litRequired, relightSlots } from './lighting.mjs'
|
|
7
7
|
|
|
8
8
|
/** What an hour is worth when nothing says otherwise. */
|
|
9
9
|
const FALLBACK_SLOT_MINUTES = 45
|
|
@@ -38,13 +38,19 @@ function minutesToTime(minutes) {
|
|
|
38
38
|
* row can say whether it needs a lit court.
|
|
39
39
|
* @param {number} [params.lightBufferMinutes] How long before that an
|
|
40
40
|
* unlit court stops taking new play.
|
|
41
|
+
* @param {Array} [params.dayLighting] Days with a dark from or a light
|
|
42
|
+
* buffer of their own, which overrule the two above on that date.
|
|
41
43
|
* @returns {Array<{key: string, label: string, start_minutes: number,
|
|
42
44
|
* end_minutes: number}>}
|
|
43
45
|
*/
|
|
44
46
|
export function rowsForDate({
|
|
45
47
|
date, competitions, cells, fallbackSlots = [],
|
|
46
|
-
darkFromMinutes = null, lightBufferMinutes = 0,
|
|
48
|
+
darkFromMinutes: eventDarkFrom = null, lightBufferMinutes: eventBuffer = 0,
|
|
49
|
+
dayLighting = null,
|
|
47
50
|
}) {
|
|
51
|
+
const { darkFromMinutes, lightBufferMinutes, ownDarkFrom } = lightingForDate(
|
|
52
|
+
dayLighting, date, eventDarkFrom, eventBuffer
|
|
53
|
+
)
|
|
48
54
|
|
|
49
55
|
const byKey = new Map()
|
|
50
56
|
|
|
@@ -96,7 +102,11 @@ export function rowsForDate({
|
|
|
96
102
|
|
|
97
103
|
// A day nothing is configured for still needs a grid to drag on.
|
|
98
104
|
if (byKey.size === 0) {
|
|
99
|
-
|
|
105
|
+
const fallback = ownDarkFrom
|
|
106
|
+
? relightSlots(fallbackSlots ?? [], darkFromMinutes)
|
|
107
|
+
: fallbackSlots ?? []
|
|
108
|
+
|
|
109
|
+
for (const slot of fallback) {
|
|
100
110
|
byKey.set(slot.key, slot)
|
|
101
111
|
}
|
|
102
112
|
}
|
|
@@ -180,7 +180,8 @@ export function fourTeamMatches(group) {
|
|
|
180
180
|
* named entry point onto that shared math.
|
|
181
181
|
*
|
|
182
182
|
* @param {number} count
|
|
183
|
-
* @param {
|
|
183
|
+
* @param {object} [options]
|
|
184
|
+
* @param {boolean} [options.dense]
|
|
184
185
|
* @returns {number[]} unit sizes, largest first
|
|
185
186
|
*/
|
|
186
187
|
export function fourTeamUnitSizes(count, { dense = false } = {}) {
|
package/lib/index.mjs
CHANGED
package/lib/knockout-draw.mjs
CHANGED
package/lib/knockout-planner.mjs
CHANGED
|
@@ -66,7 +66,11 @@ function holdCourts(item, pool, day, where, row) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
const at = court => row.indexOf(court)
|
|
69
|
-
const
|
|
69
|
+
const slot = where.slots[where.slotIndex]
|
|
70
|
+
const usable = new Set(day.usable(row, slot))
|
|
71
|
+
const busy = new Set(
|
|
72
|
+
row.filter(court => usable.has(court) && !pool.includes(court)).map(at)
|
|
73
|
+
)
|
|
70
74
|
// Where the draw played its last round: one that has to move moves
|
|
71
75
|
// as little as it can.
|
|
72
76
|
const were = (item.draw.lastCourts ?? []).map(id => row.findIndex(c => c.id === id))
|
package/lib/lighting.mjs
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* attribute on the element; deciding what that means for a court is a
|
|
18
18
|
* scheduling decision and belongs client-side (spec.md).
|
|
19
19
|
*
|
|
20
|
-
* @param {
|
|
20
|
+
* @param {object} state
|
|
21
|
+
* @param {Array<{end_minutes: (number|undefined)}>} state.slots
|
|
21
22
|
* @param {string} [darkFromTime] "HH:MM"
|
|
22
23
|
* @returns {Object} the same state, its slots carrying lit_required
|
|
23
24
|
*/
|
|
@@ -94,3 +95,44 @@ export function toMinutes(time) {
|
|
|
94
95
|
|
|
95
96
|
return Number.isFinite(hours) ? hours * 60 + (Number.isFinite(minutes) ? minutes : 0) : null
|
|
96
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The lighting that applies on one date: the day's own dark from and
|
|
101
|
+
* light buffer where it has them, the event's where it does not.
|
|
102
|
+
*
|
|
103
|
+
* @param {?Array<{date: string, dark_from: ?string, light_buffer: ?number}>} dayLighting
|
|
104
|
+
* @param {string} date
|
|
105
|
+
* @param {?number} darkFromMinutes The event's.
|
|
106
|
+
* @param {(number|undefined)} lightBufferMinutes The event's.
|
|
107
|
+
* @returns {{darkFromMinutes: ?number, lightBufferMinutes: (number|undefined), ownDarkFrom: boolean}}
|
|
108
|
+
*/
|
|
109
|
+
export function lightingForDate(
|
|
110
|
+
dayLighting, date, darkFromMinutes, lightBufferMinutes
|
|
111
|
+
) {
|
|
112
|
+
const own = (dayLighting ?? []).find(day => day.date === date)
|
|
113
|
+
const ownDarkFrom = Boolean(own?.dark_from)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
darkFromMinutes: ownDarkFrom ? toMinutes(own.dark_from) : darkFromMinutes,
|
|
117
|
+
lightBufferMinutes: own?.light_buffer == null
|
|
118
|
+
? lightBufferMinutes
|
|
119
|
+
: Number(own.light_buffer),
|
|
120
|
+
ownDarkFrom,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Slots with lit_required read off the given dark from, for a day
|
|
126
|
+
* that has one of its own. The slots on the board carry the event's
|
|
127
|
+
* answer, which that day overrules.
|
|
128
|
+
*
|
|
129
|
+
* @param {Array} slots
|
|
130
|
+
* @param {?number} darkFromMinutes
|
|
131
|
+
* @returns {Array}
|
|
132
|
+
*/
|
|
133
|
+
export function relightSlots(slots, darkFromMinutes) {
|
|
134
|
+
return slots.map(slot => ({
|
|
135
|
+
...slot,
|
|
136
|
+
lit_required: darkFromMinutes != null && slot.end_minutes > darkFromMinutes,
|
|
137
|
+
}))
|
|
138
|
+
}
|
package/lib/occupancy.mjs
CHANGED
|
@@ -66,6 +66,11 @@ export function courtCanHost(court, slots, darkFromMinutes) {
|
|
|
66
66
|
return false
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
if (court.availableUntilMinutes != null
|
|
70
|
+
&& slots.some(slot => slot.end_minutes > court.availableUntilMinutes)) {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
if (darkFromMinutes == null || court.lit) {
|
|
70
75
|
return true
|
|
71
76
|
}
|
|
@@ -51,9 +51,10 @@ import { courtCanHost } from './occupancy.mjs'
|
|
|
51
51
|
* group shape's own round robin, one entry per round - see
|
|
52
52
|
* group-schedule.mjs's GROUP_TEMPLATES and four-team-planner.mjs's
|
|
53
53
|
* FOUR_TEAM_TEMPLATE. A round's pairs.length is courtsPerRound.
|
|
54
|
-
* @param {(
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* @param {function(object): Array<object>} params.matchesFor
|
|
55
|
+
* Given a group, {id, teamIds}, its own matches as {slot, pairIndex,
|
|
56
|
+
* index}, flattened and indexed in play order.
|
|
57
|
+
* @param {function(number, number): boolean} [params.done]
|
|
57
58
|
* Which of them are already played, for a day laid out into a plan
|
|
58
59
|
* that exists.
|
|
59
60
|
* @returns {Array<Array<{groupId: number, index: number}|null>>} slot by court
|
|
@@ -167,7 +168,8 @@ function remainderLayout(groups, { template, matchesFor, done }) {
|
|
|
167
168
|
* slots for no reason.
|
|
168
169
|
*
|
|
169
170
|
* @param {Array<Array<{groupId: number, index: number}|null>>} layout
|
|
170
|
-
* @param {(
|
|
171
|
+
* @param {function(number): object} lookup Given a group id, its {id,
|
|
172
|
+
* competitionId, divisionId, divisionName, divisionGender, groupName}.
|
|
171
173
|
*/
|
|
172
174
|
export function placeLayout({ layout, lookup, courts, slots, date, occupancy, darkFrom, blocks }) {
|
|
173
175
|
if (layout.length === 0) {
|
|
@@ -252,7 +254,8 @@ export function placeLayout({ layout, lookup, courts, slots, date, occupancy, da
|
|
|
252
254
|
* 7 groups -> 3, 2, 2 9 groups -> 3, 3, 3
|
|
253
255
|
*
|
|
254
256
|
* @param {number} count
|
|
255
|
-
* @param {
|
|
257
|
+
* @param {object} [options]
|
|
258
|
+
* @param {boolean} [options.dense]
|
|
256
259
|
* @returns {number[]} unit sizes, largest first
|
|
257
260
|
*/
|
|
258
261
|
export function unitSizes(count, { dense = false } = {}) {
|
package/lib/roster-seeding.mjs
CHANGED
|
@@ -47,19 +47,11 @@ export function seedGroups(teams, groupCount) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* 1, 2, 3, ... for group naming.
|
|
51
51
|
*
|
|
52
52
|
* @param {number} index zero-based
|
|
53
53
|
* @returns {string}
|
|
54
54
|
*/
|
|
55
55
|
export function groupName(index) {
|
|
56
|
-
|
|
57
|
-
let n = index
|
|
58
|
-
|
|
59
|
-
do {
|
|
60
|
-
name = String.fromCharCode(65 + (n % 26)) + name
|
|
61
|
-
n = Math.floor(n / 26) - 1
|
|
62
|
-
} while (n >= 0)
|
|
63
|
-
|
|
64
|
-
return name
|
|
56
|
+
return String(index + 1)
|
|
65
57
|
}
|
package/package.json
CHANGED