@opndev/tennis-match-center 0.0.1
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 +5 -0
- package/LICENSE +235 -0
- package/README.md +218 -0
- package/lib/auto-planner.mjs +1297 -0
- package/lib/bracket-autofill.mjs +174 -0
- package/lib/bracket-single-elimination.mjs +496 -0
- package/lib/busy-button.mjs +58 -0
- package/lib/components/bracket-board.mjs +586 -0
- package/lib/components/planning-board.mjs +1179 -0
- package/lib/components/roster-board.mjs +273 -0
- package/lib/components/roster-team.mjs +92 -0
- package/lib/components/schedule-board.mjs +1193 -0
- package/lib/court-bands.mjs +185 -0
- package/lib/court-runs.mjs +76 -0
- package/lib/day-rows.mjs +116 -0
- package/lib/division-hues.mjs +52 -0
- package/lib/five-team-planner.mjs +21 -0
- package/lib/flags.mjs +60 -0
- package/lib/four-team-planner.mjs +216 -0
- package/lib/group-schedule.mjs +175 -0
- package/lib/index.mjs +9 -0
- package/lib/knockout-draw.mjs +209 -0
- package/lib/knockout-planner.mjs +573 -0
- package/lib/knockout-waves.mjs +156 -0
- package/lib/lighting.mjs +50 -0
- package/lib/match-time.mjs +13 -0
- package/lib/occupancy.mjs +74 -0
- package/lib/paired-court-planner.mjs +236 -0
- package/lib/plan-placement.mjs +219 -0
- package/lib/plan.mjs +133 -0
- package/lib/planning-day.mjs +177 -0
- package/lib/roster-seeding.mjs +65 -0
- package/lib/run-queue-planner.mjs +603 -0
- package/lib/sessions.mjs +48 -0
- package/lib/three-team-planner.mjs +122 -0
- package/lib/webcomponents.mjs +25 -0
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
- package/package.json +39 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Places generated matches into the cells Planning booked for them.
|
|
7
|
+
*
|
|
8
|
+
* This deliberately contains no scheduling logic. Planning already
|
|
9
|
+
* decided which division and round occupies each court and slot; the
|
|
10
|
+
* only job here is to bind those decisions to concrete matches and
|
|
11
|
+
* report anything left over. When Planning and Scheduling each ran
|
|
12
|
+
* their own layout they disagreed, and cells were reserved that no
|
|
13
|
+
* match could be placed into.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { templateMatches } from './group-schedule.mjs'
|
|
17
|
+
|
|
18
|
+
function fixtureKey(divisionId, round) {
|
|
19
|
+
return `${divisionId}:${round}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether a bracket row is a bye: one side filled, the other empty.
|
|
24
|
+
* Both empty is a fixture awaiting winners, and does need a cell.
|
|
25
|
+
*
|
|
26
|
+
* @param {{home_team_id: ?number, visitor_team_id: ?number}} match
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isBye(match) {
|
|
30
|
+
return (match.home_team_id == null) !== (match.visitor_team_id == null)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Object} params
|
|
35
|
+
* @param {Array<{date: string, court_id: number, slot: string, division_id: ?number, round: ?string}>} params.cells
|
|
36
|
+
* Planning's cells, in the order Planning saved them.
|
|
37
|
+
* @param {Array<{rounds: Array<{name: string, matches: Array<Object>}>}>} params.brackets
|
|
38
|
+
* The saved bracket shells, grouped by round.
|
|
39
|
+
* @returns {{matches: Array<Object>, unplaced: Array<Object>, unusedCells: Array<Object>}}
|
|
40
|
+
* unplaced is a match Planning reserved no cell for - a planning
|
|
41
|
+
* error. unusedCells is a cell with no match to put in it, which is
|
|
42
|
+
* expected: Planning reserves full capacity, Scheduling fills actual.
|
|
43
|
+
*/
|
|
44
|
+
export function placeKnockoutIntoCells({ cells, brackets }) {
|
|
45
|
+
const pool = new Map()
|
|
46
|
+
|
|
47
|
+
for (const bracket of brackets) {
|
|
48
|
+
for (const round of bracket.rounds) {
|
|
49
|
+
for (const match of round.matches) {
|
|
50
|
+
// A bye advances a team without being played, so it takes no
|
|
51
|
+
// cell. Planning counts a round's court time the same way -
|
|
52
|
+
// see knockout-waves.mjs - and counting it here instead would
|
|
53
|
+
// report the difference as capacity Planning failed to book.
|
|
54
|
+
if (isBye(match)) {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const key = fixtureKey(match.division_id, round.name)
|
|
59
|
+
|
|
60
|
+
if (!pool.has(key)) {
|
|
61
|
+
pool.set(key, [])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pool.get(key).push({ ...match, round: round.name })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const matches = []
|
|
70
|
+
const unusedCells = []
|
|
71
|
+
|
|
72
|
+
for (const cell of cells) {
|
|
73
|
+
if (cell.division_id == null || !cell.round) {
|
|
74
|
+
unusedCells.push(cell)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const shell = pool.get(fixtureKey(cell.division_id, cell.round))?.shift()
|
|
79
|
+
|
|
80
|
+
if (!shell) {
|
|
81
|
+
unusedCells.push(cell)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
matches.push({
|
|
86
|
+
id: shell.id,
|
|
87
|
+
division_id: cell.division_id,
|
|
88
|
+
group_id: null,
|
|
89
|
+
round: cell.round,
|
|
90
|
+
slot: null,
|
|
91
|
+
court_id: cell.court_id,
|
|
92
|
+
date: cell.date,
|
|
93
|
+
hour: cell.slot,
|
|
94
|
+
home_team_id: shell.home_team_id,
|
|
95
|
+
visitor_team_id: shell.visitor_team_id,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
matches,
|
|
101
|
+
unplaced: [...pool.values()].flat(),
|
|
102
|
+
unusedCells,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Places a group's matches into the cells Planning booked for them.
|
|
108
|
+
*
|
|
109
|
+
* A cell names one fixture - a group and an index into that group's
|
|
110
|
+
* fixed template - so there is nothing to work out here. The template
|
|
111
|
+
* gives the pair of seed positions and the group gives the teams
|
|
112
|
+
* sitting in them.
|
|
113
|
+
*
|
|
114
|
+
* @param {Object} params
|
|
115
|
+
* @param {Array<{date: string, court_id: number, slot: string, group_id: ?number, match_slot: ?number}>} params.cells
|
|
116
|
+
* Planning's cells, in the order Planning saved them.
|
|
117
|
+
* @param {Array<{id: number, divisionId: number, teamIds: number[], groupSize: 4|5}>} params.groups
|
|
118
|
+
* @returns {{matches: Array<Object>, unplaced: Array<Object>, unusedCells: Array<Object>}}
|
|
119
|
+
* unplaced is a match Planning booked no cell for - a planning
|
|
120
|
+
* error. unusedCells is a booked cell with no match to put in it,
|
|
121
|
+
* which is expected: Planning reserves full capacity while
|
|
122
|
+
* Scheduling fills what actually exists.
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* A group's fixtures, in template order, with the seats resolved.
|
|
126
|
+
*
|
|
127
|
+
* Planning books a short group's cells against the size it is played
|
|
128
|
+
* at, padding the seats nobody filled, so the template is that one.
|
|
129
|
+
* A fixture with one seat empty is a bye and is played; one with both
|
|
130
|
+
* empty is no fixture at all, and says so.
|
|
131
|
+
*
|
|
132
|
+
* @param {{teamIds: number[], groupSize: number}} group
|
|
133
|
+
* @returns {Array<{slot: number, homeId: ?number, visitorId: ?number,
|
|
134
|
+
* playable: boolean}>}
|
|
135
|
+
*/
|
|
136
|
+
export function groupFixtures(group) {
|
|
137
|
+
const seats = [...group.teamIds]
|
|
138
|
+
|
|
139
|
+
while (seats.length < (group.groupSize ?? seats.length)) {
|
|
140
|
+
seats.push(null)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return templateMatches({ teamIds: seats, groupSize: group.groupSize })
|
|
144
|
+
.map(entry => {
|
|
145
|
+
const homeId = seats[entry.pair[0] - 1] ?? null
|
|
146
|
+
const visitorId = seats[entry.pair[1] - 1] ?? null
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
slot: entry.slot,
|
|
150
|
+
homeId,
|
|
151
|
+
visitorId,
|
|
152
|
+
playable: homeId !== null || visitorId !== null,
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function placeGroupsIntoCells({ cells, groups }) {
|
|
158
|
+
const byId = new Map(groups.map(group => [group.id, group]))
|
|
159
|
+
const templates = new Map(
|
|
160
|
+
groups.map(group => [group.id, groupFixtures(group)])
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const matches = []
|
|
164
|
+
const unusedCells = []
|
|
165
|
+
const taken = new Set()
|
|
166
|
+
|
|
167
|
+
for (const cell of cells) {
|
|
168
|
+
if (cell.group_id == null || cell.match_slot == null) {
|
|
169
|
+
unusedCells.push(cell)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const group = byId.get(cell.group_id)
|
|
174
|
+
const entry = templates.get(cell.group_id)?.[cell.match_slot]
|
|
175
|
+
const key = `${cell.group_id}:${cell.match_slot}`
|
|
176
|
+
|
|
177
|
+
// A group that has since been resized can leave a cell pointing at
|
|
178
|
+
// a match its template no longer has.
|
|
179
|
+
if (!group || !entry || taken.has(key)) {
|
|
180
|
+
unusedCells.push(cell)
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
taken.add(key)
|
|
185
|
+
|
|
186
|
+
if (!entry.playable) {
|
|
187
|
+
unusedCells.push(cell)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
matches.push({
|
|
192
|
+
id: null,
|
|
193
|
+
round: 'group',
|
|
194
|
+
division_id: group.divisionId,
|
|
195
|
+
group_id: group.id,
|
|
196
|
+
// Which fixture of the group this is, not which round of it: a
|
|
197
|
+
// four team group plays two fixtures a round, and a match is one
|
|
198
|
+
// per (group, slot).
|
|
199
|
+
slot: cell.match_slot,
|
|
200
|
+
court_id: cell.court_id,
|
|
201
|
+
date: cell.date,
|
|
202
|
+
hour: cell.slot,
|
|
203
|
+
home_team_id: entry.homeId,
|
|
204
|
+
visitor_team_id: entry.visitorId,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const unplaced = []
|
|
209
|
+
|
|
210
|
+
for (const group of groups) {
|
|
211
|
+
templates.get(group.id).forEach((entry, index) => {
|
|
212
|
+
if (entry.playable && !taken.has(`${group.id}:${index}`)) {
|
|
213
|
+
unplaced.push({ group_id: group.id, match_slot: index, slot: entry.slot })
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { matches, unplaced, unusedCells }
|
|
219
|
+
}
|
package/lib/plan.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The plan being built: the cells that landed, the cells that were
|
|
7
|
+
* taken back, and everything the day could not honour.
|
|
8
|
+
*
|
|
9
|
+
* Every count that ends up in the shortfall report is registered here
|
|
10
|
+
* as it is discovered, rather than gathered from half a dozen maps at
|
|
11
|
+
* the end. A caller asks it for the finished result once.
|
|
12
|
+
*/
|
|
13
|
+
export class Plan {
|
|
14
|
+
|
|
15
|
+
constructor() {
|
|
16
|
+
this.blocks = []
|
|
17
|
+
this.warnings = []
|
|
18
|
+
|
|
19
|
+
this.refusedCells = new Map()
|
|
20
|
+
this.dropped = new Set()
|
|
21
|
+
this.missing = new Map()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Books a cell.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} block
|
|
28
|
+
*/
|
|
29
|
+
place(block) {
|
|
30
|
+
this.blocks.push(block)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every cell a competition holds for one date and phase.
|
|
35
|
+
*
|
|
36
|
+
* @param {Object} competition
|
|
37
|
+
* @param {string} date
|
|
38
|
+
* @param {string} phase
|
|
39
|
+
* @returns {Array}
|
|
40
|
+
*/
|
|
41
|
+
cellsFor(competition, date, phase) {
|
|
42
|
+
return this.blocks.filter(block =>
|
|
43
|
+
block.competition_id === competition.id
|
|
44
|
+
&& block.date === date
|
|
45
|
+
&& block.phase === phase
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Takes a competition's cells back off it, counting them as capacity
|
|
51
|
+
* it did not get. The cells are left for whoever can use them.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} competition
|
|
54
|
+
* @param {string} phase
|
|
55
|
+
* @param {Array} blocks The cells to withdraw.
|
|
56
|
+
*/
|
|
57
|
+
refuse(competition, phase, blocks) {
|
|
58
|
+
this.short(competition, phase, blocks.length)
|
|
59
|
+
|
|
60
|
+
for (const block of blocks) {
|
|
61
|
+
this.dropped.add(block)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Records matches a competition needed and did not get.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object|number} competition The competition or its id.
|
|
69
|
+
* @param {string} phase 'group' or 'knockout'.
|
|
70
|
+
* @param {number} count
|
|
71
|
+
*/
|
|
72
|
+
short(competition, phase, count) {
|
|
73
|
+
if (count <= 0) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const id = typeof competition === 'object' ? competition.id : competition
|
|
78
|
+
const key = `${id}:${phase}`
|
|
79
|
+
|
|
80
|
+
this.missing.set(key, (this.missing.get(key) ?? 0) + count)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How much a competition is short in one phase.
|
|
85
|
+
*
|
|
86
|
+
* @param {Object|number} competition
|
|
87
|
+
* @param {string} phase
|
|
88
|
+
* @returns {number}
|
|
89
|
+
*/
|
|
90
|
+
shortfallFor(competition, phase) {
|
|
91
|
+
const id = typeof competition === 'object' ? competition.id : competition
|
|
92
|
+
|
|
93
|
+
return this.missing.get(`${id}:${phase}`) ?? 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Notes something the plan had to guess at or could not honour.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} warning
|
|
100
|
+
*/
|
|
101
|
+
warn(warning) {
|
|
102
|
+
this.warnings.push(warning)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The finished plan: the cells that survived, what each competition
|
|
107
|
+
* is short by, and the warnings raised along the way.
|
|
108
|
+
*
|
|
109
|
+
* @param {Array} competitions In the order they should be reported.
|
|
110
|
+
* @returns {{blocks: Array, shortfall: Array, warnings: Array}}
|
|
111
|
+
*/
|
|
112
|
+
result(competitions) {
|
|
113
|
+
const blocks = this.dropped.size === 0
|
|
114
|
+
? this.blocks
|
|
115
|
+
: this.blocks.filter(block => !this.dropped.has(block))
|
|
116
|
+
|
|
117
|
+
const shortfall = []
|
|
118
|
+
|
|
119
|
+
for (const competition of competitions) {
|
|
120
|
+
for (const phase of ['group', 'knockout']) {
|
|
121
|
+
const missing = this.shortfallFor(competition, phase)
|
|
122
|
+
|
|
123
|
+
if (missing > 0) {
|
|
124
|
+
shortfall.push({
|
|
125
|
+
competition_id: competition.id, phase, missing,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { blocks, shortfall, warnings: this.warnings }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { courtUsableAt, runFits } from './court-runs.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long before dark an unlit court stops taking new play.
|
|
9
|
+
*
|
|
10
|
+
* A match that ends exactly as the light goes is pushing it: matches
|
|
11
|
+
* overrun, and the overrun trickles through the rest of the evening
|
|
12
|
+
* with nowhere to finish. An unlit court is left out of the last hour
|
|
13
|
+
* of daylight rather than relied on for it.
|
|
14
|
+
*/
|
|
15
|
+
const LIGHT_BUFFER_MINUTES = 60
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One date of the event as the planner works through it: the courts it
|
|
19
|
+
* has, the slots each competition runs on it, what is already booked,
|
|
20
|
+
* and the plan the cells go into.
|
|
21
|
+
*
|
|
22
|
+
* Everything the placers used to be handed field by field is asked of
|
|
23
|
+
* this instead - which courts a lane may use, what is free at a given
|
|
24
|
+
* slot, whether a court could hold a whole run - so a placer needs the
|
|
25
|
+
* day and nothing else.
|
|
26
|
+
*/
|
|
27
|
+
export class PlanningDay {
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Object} params
|
|
31
|
+
* @param {string} params.date Y-m-d.
|
|
32
|
+
* @param {Array} params.proCourts Courts reserved for pro play.
|
|
33
|
+
* @param {Array} params.openCourts Courts anyone may use, already
|
|
34
|
+
* carrying any per-day override (see openCourtsFor).
|
|
35
|
+
* @param {Map<number, Array>} params.slotsByCompetition Each
|
|
36
|
+
* competition's own slots for this date.
|
|
37
|
+
* @param {Object} params.occupancy What is already booked.
|
|
38
|
+
* @param {Plan} params.plan Where cells are placed.
|
|
39
|
+
* @param {?number} [params.darkFromMinutes] When the light goes, if
|
|
40
|
+
* the event says. Without it a slot's own flag is all there is.
|
|
41
|
+
*/
|
|
42
|
+
constructor({
|
|
43
|
+
date, proCourts, openCourts, slotsByCompetition, occupancy, plan,
|
|
44
|
+
darkFromMinutes = null,
|
|
45
|
+
}) {
|
|
46
|
+
this.date = date
|
|
47
|
+
this.proCourts = proCourts
|
|
48
|
+
this.openCourts = openCourts
|
|
49
|
+
this.slotsByCompetition = slotsByCompetition
|
|
50
|
+
this.occupancy = occupancy
|
|
51
|
+
this.plan = plan
|
|
52
|
+
this.darkFromMinutes = darkFromMinutes
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether this slot has to be played under lights.
|
|
57
|
+
*
|
|
58
|
+
* Its own flag says whether it runs past dark. The buffer says the
|
|
59
|
+
* hour before that is no place to start on an unlit court either.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} slot
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
needsLight(slot) {
|
|
65
|
+
if (slot.lit_required) {
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.darkFromMinutes == null) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return slot.start_minutes >= this.darkFromMinutes - LIGHT_BUFFER_MINUTES
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The courts one lane of play may use.
|
|
78
|
+
*
|
|
79
|
+
* @param {boolean} pro
|
|
80
|
+
* @returns {Array}
|
|
81
|
+
*/
|
|
82
|
+
courtsFor(pro) {
|
|
83
|
+
return pro ? this.proCourts : this.openCourts
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A competition's slots for this date.
|
|
88
|
+
*
|
|
89
|
+
* @param {number} competitionId
|
|
90
|
+
* @returns {Array}
|
|
91
|
+
*/
|
|
92
|
+
slotsFor(competitionId) {
|
|
93
|
+
return this.slotsByCompetition.get(competitionId) ?? []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether a court has nothing on it for this slot.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} court
|
|
100
|
+
* @param {Object} slot
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
isFree(court, slot) {
|
|
104
|
+
if (this.needsLight(slot) && !court.lit) {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return courtUsableAt(court, slot) && this.occupancy.free(
|
|
109
|
+
this.date, court.id, slot.start_minutes, slot.end_minutes
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Which of these courts may be played on in this slot at all,
|
|
115
|
+
* whether or not anything is on them.
|
|
116
|
+
*
|
|
117
|
+
* The venue's capacity at that hour, as against what is going spare
|
|
118
|
+
* - a round asking whether it could ever finish today measures
|
|
119
|
+
* itself against this, not against whoever happens to hold a court
|
|
120
|
+
* this minute.
|
|
121
|
+
*
|
|
122
|
+
* @param {Array} courts
|
|
123
|
+
* @param {Object} slot
|
|
124
|
+
* @returns {Array}
|
|
125
|
+
*/
|
|
126
|
+
usable(courts, slot) {
|
|
127
|
+
return courts.filter(court =>
|
|
128
|
+
courtUsableAt(court, slot) && !(this.needsLight(slot) && !court.lit)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Which of these courts are going spare for this slot.
|
|
134
|
+
*
|
|
135
|
+
* @param {Array} courts
|
|
136
|
+
* @param {Object} slot
|
|
137
|
+
* @returns {Array}
|
|
138
|
+
*/
|
|
139
|
+
free(courts, slot) {
|
|
140
|
+
return courts.filter(court => this.isFree(court, slot))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Which of these courts could hold a run of consecutive slots end to
|
|
145
|
+
* end - the question a court has to answer before it is taken.
|
|
146
|
+
*
|
|
147
|
+
* @param {Array} courts
|
|
148
|
+
* @param {Object} where Its slots and where the run starts.
|
|
149
|
+
* @param {number} runLength
|
|
150
|
+
* @returns {Array}
|
|
151
|
+
*/
|
|
152
|
+
fitting(courts, { slots, slotIndex }, runLength) {
|
|
153
|
+
const context = {
|
|
154
|
+
slots, slotIndex, date: this.date, taken: this.occupancy,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const window = slots.slice(slotIndex, slotIndex + runLength)
|
|
158
|
+
|
|
159
|
+
return courts.filter(court =>
|
|
160
|
+
runFits(court, context, runLength)
|
|
161
|
+
&& window.every(slot => this.isFree(court, slot))
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Books a cell: onto the plan, and against the court's own time.
|
|
167
|
+
*
|
|
168
|
+
* @param {Object} block
|
|
169
|
+
* @param {Object} slot The slot it runs in.
|
|
170
|
+
*/
|
|
171
|
+
place(block, slot) {
|
|
172
|
+
this.plan.place(block)
|
|
173
|
+
this.occupancy.take(
|
|
174
|
+
this.date, block.court_id, slot.start_minutes, slot.end_minutes
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Country-spread + age-balancing seeding algorithm - "Group Assignment
|
|
7
|
+
* (Seeding)" in spec.md. Pure function so it can run client-side ahead
|
|
8
|
+
* of any save and be unit tested without a DOM.
|
|
9
|
+
*
|
|
10
|
+
* @param {Array<{id: number, country: ?string, aruba_resident: boolean, age: ?number}>} teams
|
|
11
|
+
* @param {number} groupCount
|
|
12
|
+
* @returns {number[][]} team ids, one array per group, in seed order
|
|
13
|
+
*/
|
|
14
|
+
export function seedGroups(teams, groupCount) {
|
|
15
|
+
if (groupCount <= 0) {
|
|
16
|
+
return []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const buckets = new Map()
|
|
20
|
+
|
|
21
|
+
for (const team of teams) {
|
|
22
|
+
const key = team.aruba_resident ? '__ARUBA__' : (team.country ?? '__UNKNOWN__')
|
|
23
|
+
|
|
24
|
+
if (!buckets.has(key)) {
|
|
25
|
+
buckets.set(key, [])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
buckets.get(key).push(team)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Largest bucket first, so the most numerous country/resident group is
|
|
32
|
+
// spread across groups before smaller ones fill in the gaps.
|
|
33
|
+
const orderedBuckets = [...buckets.values()]
|
|
34
|
+
.sort((a, b) => b.length - a.length)
|
|
35
|
+
|
|
36
|
+
const sortedTeams = orderedBuckets.flatMap(bucket => {
|
|
37
|
+
return [...bucket].sort((a, b) => (a.age ?? 0) - (b.age ?? 0))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const groups = Array.from({ length: groupCount }, () => [])
|
|
41
|
+
|
|
42
|
+
sortedTeams.forEach((team, index) => {
|
|
43
|
+
groups[index % groupCount].push(team.id)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return groups
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A, B, C, ... Z, AA, AB, ... for group naming.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} index zero-based
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function groupName(index) {
|
|
56
|
+
let name = ''
|
|
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
|
|
65
|
+
}
|