@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.
@@ -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
+ }