@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,216 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import {
6
+ buildUnits as buildPairedUnits,
7
+ placeLayout,
8
+ unitLayout,
9
+ unitSizes,
10
+ } from './paired-court-planner.mjs'
11
+
12
+ export { placeLayout }
13
+
14
+ /**
15
+ * Plans 4-team groups.
16
+ *
17
+ * A 4-team group does not take a court to itself. It plays its round
18
+ * robin as three rounds of two matches, a round takes both courts at
19
+ * once, and groups take turns: every group's first round, then every
20
+ * group's second, then its third.
21
+ *
22
+ * 1: A v B C v D two groups, six slots
23
+ * 2: A v B C v D three groups, nine slots
24
+ * 1: A v C B v D
25
+ * 2: A v C B v D no slot idle, and a group waits out the
26
+ * 1: A v D B v C others between its own rounds - which is
27
+ * 2: A v D B v C what the bundling is for
28
+ *
29
+ * The bundling and layout mechanics themselves live in
30
+ * paired-court-planner.mjs, shared with three-team-planner.mjs (its
31
+ * own round robin needs only one court a round, not two, but the same
32
+ * "groups take turns" bundling gives it the same rest). This module
33
+ * owns only what is genuinely 4-team's own: its template, and the
34
+ * public names callers already depend on.
35
+ *
36
+ * Groups are pooled across divisions on purpose: a division with a
37
+ * single 4-team group has nobody of its own to share with.
38
+ */
39
+
40
+ /**
41
+ * @param {Object} params
42
+ * @param {Array<Object>} params.groups
43
+ * {id, competitionId, divisionId, divisionName, divisionGender, groupName, teamIds, poolSize}
44
+ * @param {Array<Object>} params.courts in the order they should be used
45
+ * @param {Array<Object>} params.slots the day's slots, with start/end minutes
46
+ * @param {string} params.date
47
+ * @param {Object} params.occupancy shared with the other planner
48
+ * @param {?number} params.darkFrom minutes past midnight
49
+ * @param {boolean} params.dense pack into fewer pairs when courts are scarce
50
+ * @returns {{blocks: Array<Object>, unplaced: Array<Object>}}
51
+ */
52
+ export function planFourTeamGroups({
53
+ groups,
54
+ courts,
55
+ slots,
56
+ date,
57
+ occupancy,
58
+ darkFrom = null,
59
+ dense = false,
60
+ }) {
61
+ const blocks = []
62
+ const unplaced = []
63
+
64
+ for (const unit of buildUnits(groups, { dense })) {
65
+ const placed = placeUnit({ unit, courts, slots, date, occupancy, darkFrom, blocks })
66
+
67
+ if (!placed) {
68
+ unplaced.push(...unit)
69
+ }
70
+ }
71
+
72
+ return { blocks, unplaced }
73
+ }
74
+
75
+ /**
76
+ * Splits the groups into units sharing a pair.
77
+ *
78
+ * What decides the split is parity, not greed: a group left over on
79
+ * its own has nobody to share with, so an odd count takes exactly one
80
+ * unit of three and pairs off the rest.
81
+ */
82
+ export function buildUnits(groups, { dense = false } = {}) {
83
+ return buildPairedUnits(groups, { dense })
84
+ }
85
+
86
+ /**
87
+ * The first pair of adjacent courts that can host the whole unit, and
88
+ * the earliest start on it. See placeLayout - this just computes the
89
+ * unit's layout and looks its groups up by id.
90
+ */
91
+ function placeUnit({ unit, courts, slots, date, occupancy, darkFrom, blocks }) {
92
+ const layout = fourTeamUnitLayout(unit.map(group => ({
93
+ id: group.id,
94
+ teamIds: group.teamIds,
95
+ groupSize: group.poolSize,
96
+ })))
97
+
98
+ return placeLayout({
99
+ layout,
100
+ lookup: groupId => unit.find(entry => entry.id === groupId),
101
+ courts,
102
+ slots,
103
+ date,
104
+ occupancy,
105
+ darkFrom,
106
+ blocks,
107
+ })
108
+ }
109
+
110
+ /**
111
+ * A 4-team group's round robin: three rounds of two matches, a round
112
+ * taking both courts of the pair at once.
113
+ */
114
+ export const FOUR_TEAM_TEMPLATE = [
115
+ { slot: 1, day: 1, pairs: [[1, 2], [3, 4]] },
116
+ { slot: 2, day: 1, pairs: [[1, 3], [2, 4]] },
117
+ { slot: 3, day: 1, pairs: [[1, 4], [2, 3]] },
118
+ ]
119
+
120
+ /**
121
+ * The template with every seat above `teams` left vacant.
122
+ *
123
+ * Three teams in a division drawn for four is this same group with one
124
+ * bye, not a three-team round robin. It keeps its turn on the pair and
125
+ * its rest still comes from waiting that turn out; the vacant seat has
126
+ * no match, so the partner court simply stands empty while it plays.
127
+ *
128
+ * @param {number} teams
129
+ */
130
+ export function fourTeamTemplate(teams) {
131
+ if (teams >= 4) {
132
+ return FOUR_TEAM_TEMPLATE
133
+ }
134
+
135
+ return FOUR_TEAM_TEMPLATE
136
+ .map(entry => ({
137
+ ...entry,
138
+ pairs: entry.pairs.filter(([home, visitor]) => home <= teams && visitor <= teams),
139
+ }))
140
+ .filter(entry => entry.pairs.length > 0)
141
+ }
142
+
143
+ /**
144
+ * A group's matches in play order.
145
+ *
146
+ * A slot holds two pairs playing at once on two courts, so the flat
147
+ * index - not the template slot number - is what a planned cell
148
+ * records as its match_slot, and what Scheduling reads back to know
149
+ * which pair belongs in the cell.
150
+ *
151
+ * @param {{teamIds: number[]}} group
152
+ */
153
+ export function fourTeamMatches(group) {
154
+ const matches = []
155
+ const seenInDay = new Map()
156
+
157
+ for (const entry of fourTeamTemplate(group.teamIds.length)) {
158
+ const position = (seenInDay.get(entry.day) ?? 0) + 1
159
+ seenInDay.set(entry.day, position)
160
+
161
+ entry.pairs.forEach((pair, pairIndex) => {
162
+ matches.push({
163
+ index: matches.length,
164
+ slot: entry.slot,
165
+ runSlot: entry.runSlot ?? position,
166
+ day: entry.day,
167
+ pairIndex,
168
+ pair,
169
+ })
170
+ })
171
+ }
172
+
173
+ return matches
174
+ }
175
+
176
+ /**
177
+ * How a set of 4-team groups is split into units sharing a court pair.
178
+ *
179
+ * See paired-court-planner.mjs's unitSizes - this is a thin, shape-
180
+ * named entry point onto that shared math.
181
+ *
182
+ * @param {number} count
183
+ * @param {{dense?: boolean}} options
184
+ * @returns {number[]} unit sizes, largest first
185
+ */
186
+ export function fourTeamUnitSizes(count, { dense = false } = {}) {
187
+ return unitSizes(count, { dense })
188
+ }
189
+
190
+ /**
191
+ * Lays out a set of 4-team groups sharing one pair of courts.
192
+ *
193
+ * A 4-team group plays its round robin as three rounds of two matches,
194
+ * and a round takes both courts at once. Groups take turns: every
195
+ * group plays its first round, then every group its second, then its
196
+ * third.
197
+ *
198
+ * Nothing sits idle, and a group waits out as many slots between its
199
+ * own rounds as there are other groups in the unit. That is what the
200
+ * bundling is for - two groups alone on a pair cannot fill the gap
201
+ * their own rest requires.
202
+ *
203
+ * Two groups, six slots Three groups, nine slots
204
+ * 1: A v B C v D 1: A v B C v D
205
+ * 2: A v B C v D 2: A v B C v D
206
+ * 1: A v C B v D 3: A v B C v D
207
+ * 2: A v C B v D 1: A v C B v D
208
+ * 1: A v D B v C ...
209
+ * 2: A v D B v C
210
+ *
211
+ * @param {Array<{id: number, teamIds: number[]}>} groups
212
+ * @returns {Array<Array<{groupId: number, index: number}>>} slot by court
213
+ */
214
+ export function fourTeamUnitLayout(groups) {
215
+ return unitLayout(groups, { template: FOUR_TEAM_TEMPLATE, matchesFor: fourTeamMatches })
216
+ }
@@ -0,0 +1,175 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { fourTeamTemplate } from './four-team-planner.mjs'
6
+
7
+ /**
8
+ * The fixed group-stage match templates - "Group Stage Scheduling" in
9
+ * spec.md. Each entry is one "slot": a set of matches that share no
10
+ * team and so can run at the same real hour. Different slots for the
11
+ * same group always share at least one team with a neighbouring slot,
12
+ * so they must run at strictly later real hours - see
13
+ * scheduleGroupMatches's readyAtHourIndex.
14
+ *
15
+ * Pairs are 1-based positions into a group's team_ids (seed order),
16
+ * matching spec.md's tables directly.
17
+ *
18
+ * The 4-team template is played on two courts at once (spec.md: "6
19
+ * matches per group, 1 day, 2 courts"), with two or three groups
20
+ * rotating through the same pair, one group's whole round at a time.
21
+ *
22
+ * A group that ends up with only 4 real teams in a division otherwise
23
+ * configured for 5 uses that same 4-team template. It cannot play
24
+ * three matches a day on a court of its own - a round robin of four
25
+ * has six pairs, and after any two with no shared team every
26
+ * remaining pair reuses one - so it is bundled with the other short
27
+ * groups, and its rest comes from waiting its turn on the pair.
28
+ */
29
+ export { fourTeamUnitSizes, fourTeamUnitLayout } from './four-team-planner.mjs'
30
+
31
+ export const GROUP_TEMPLATES = {
32
+ // Team Cup: one court per group, its three encounters in a cycle.
33
+ // Every pair of encounters in a group of three shares a team, so
34
+ // there is no ordering that rests everyone - but an encounter is
35
+ // itself three matches, so a team following one with another has
36
+ // had a long stretch off court either way.
37
+ 3: [
38
+ { slot: 1, day: 1, pairs: [[1, 2]] },
39
+ { slot: 2, day: 1, pairs: [[2, 3]] },
40
+ { slot: 3, day: 1, pairs: [[3, 1]] },
41
+ ],
42
+ 5: [
43
+ { slot: 1, day: 1, pairs: [[1, 2]] },
44
+ { slot: 2, day: 1, pairs: [[3, 4]] },
45
+ { slot: 3, day: 1, pairs: [[1, 5]] },
46
+ { slot: 4, day: 1, pairs: [[2, 3]] },
47
+ { slot: 5, day: 1, pairs: [[4, 5]] },
48
+ { slot: 6, day: 2, pairs: [[1, 3]] },
49
+ { slot: 7, day: 2, pairs: [[2, 5]] },
50
+ { slot: 8, day: 2, pairs: [[1, 4]] },
51
+ { slot: 9, day: 2, pairs: [[3, 5]] },
52
+ { slot: 10, day: 2, pairs: [[2, 4]] },
53
+ ],
54
+ }
55
+
56
+
57
+ /**
58
+ * Which template a group uses - its real team count picks it, not the
59
+ * pool size its division is configured for.
60
+ *
61
+ * @param {{teamIds: number[]}} group
62
+ */
63
+ function templateFor(group) {
64
+ const teams = group.teamIds.length
65
+ const size = group.groupSize ?? teams
66
+
67
+ // Four seats, however many of them are filled, is the 4-team
68
+ // planner's shape and it owns the bye that goes with a vacant one.
69
+ if (teams === 4 || (teams === 3 && size >= 4)) {
70
+ return fourTeamTemplate(teams)
71
+ }
72
+
73
+ return GROUP_TEMPLATES[teams] ?? null
74
+ }
75
+
76
+ /**
77
+ * A group's template flattened into individual matches, in play order.
78
+ * A 4-team slot holds two pairs playing at once on two courts, so the
79
+ * flat index - not the template slot number - is what identifies one
80
+ * match of a group.
81
+ *
82
+ * @param {{teamIds: number[], groupSize: 4|5}} group
83
+ */
84
+ export function templateMatches(group) {
85
+ const template = templateFor(group)
86
+
87
+ if (!template) {
88
+ return []
89
+ }
90
+
91
+ const matches = []
92
+ const seenInDay = new Map()
93
+
94
+ for (const entry of template) {
95
+ // Where in the day's court run this match sits. Usually that is
96
+ // just its order within the day, but a template may leave a slot
97
+ // empty to keep a team off court two matches running.
98
+ const position = (seenInDay.get(entry.day) ?? 0) + 1
99
+ seenInDay.set(entry.day, position)
100
+
101
+ entry.pairs.forEach((pair, pairIndex) => {
102
+ matches.push({
103
+ index: matches.length,
104
+ slot: entry.slot,
105
+ runSlot: entry.runSlot ?? position,
106
+ day: entry.day,
107
+ pairIndex,
108
+ pair,
109
+ })
110
+ })
111
+ }
112
+
113
+ return matches
114
+ }
115
+
116
+ /**
117
+ * The court runs a group needs: one run per day per parallel court.
118
+ * A 5-team group takes one court for five consecutive matches on each
119
+ * of two days; a 4-team group takes two courts for three each on one
120
+ * day. Planning reserves capacity in exactly these units, so this is
121
+ * what decides which cells a group is booked into.
122
+ *
123
+ * @returns {Array<{day: number, pairIndex: number, indices: number[]}>}
124
+ */
125
+ export function groupRuns(group) {
126
+ const runs = new Map()
127
+
128
+ for (const match of templateMatches(group)) {
129
+ const key = `${match.day}:${match.pairIndex}`
130
+
131
+ if (!runs.has(key)) {
132
+ runs.set(key, { day: match.day, pairIndex: match.pairIndex, indices: [], positions: [] })
133
+ }
134
+
135
+ const run = runs.get(key)
136
+
137
+ run.indices.push(match.index)
138
+
139
+ // positions is the run laid out slot by slot, so a template that
140
+ // skips a slot leaves a hole here rather than closing the gap up.
141
+ run.positions[match.runSlot - 1] = match.index
142
+ }
143
+
144
+ for (const run of runs.values()) {
145
+ for (let i = 0; i < run.positions.length; i++) {
146
+ if (run.positions[i] === undefined) {
147
+ run.positions[i] = null
148
+ }
149
+ }
150
+ }
151
+
152
+ return [...runs.values()]
153
+ }
154
+
155
+ /**
156
+ * How many matches a group's template actually has, using its real
157
+ * team count (see GROUP_TEMPLATES) rather than GAMES_PER_GROUP's
158
+ * configured-pool-size assumption - so a scheduling legend's "N/M
159
+ * scheduled" for an uneven last group compares against what it can
160
+ * really play (6 for an actual 4-team group), not the bigger template
161
+ * it was nominally configured for.
162
+ *
163
+ * @param {{teamIds: number[], groupSize: 4|5}} group
164
+ * @returns {number}
165
+ */
166
+ export function reachableMatchCount(group) {
167
+ const template = templateFor(group)
168
+
169
+ if (!template) {
170
+ return 0
171
+ }
172
+
173
+ return template.reduce((count, entry) => count + entry.pairs.length, 0)
174
+ }
175
+
package/lib/index.mjs ADDED
@@ -0,0 +1,9 @@
1
+ // SPDX-FileCopyrightText: 2023 - 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ const VERSION = "0.0.1"
6
+
7
+ export { VERSION }
8
+ export { autoFillBracket } from './bracket-autofill.mjs'
9
+
@@ -0,0 +1,209 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * How a knockout round may be cut when it cannot go on whole: in half,
7
+ * in quarters, in eighths, and nothing else - a split is a cut of the
8
+ * bracket, so the parts are the bracket's own halves. Ordered fewest
9
+ * cuts first, which is the order they are preferred in.
10
+ */
11
+ export const SPLIT_SIZES = [1, 2, 4, 8]
12
+
13
+ /**
14
+ * The smallest a part may be, below a straight halving.
15
+ *
16
+ * A round can always be halved, however narrow that leaves it: a semi
17
+ * playing 1/1 is two halves of the bracket, and the Final is after
18
+ * both. Cutting deeper than that is only a bracket cut while the parts
19
+ * still hold matches of their own - a QF goes 2/2 and no further,
20
+ * where quarters of one would have it dribbling out a match at a time.
21
+ */
22
+ const MIN_ROUND_PART = 2
23
+
24
+ /**
25
+ * One division's draw as the planner works through it: which round it
26
+ * is on, how much of that round is already booked, the courts it is
27
+ * committed to, and when it is next free.
28
+ */
29
+ export class KnockoutDraw {
30
+
31
+ /**
32
+ * @param {Object} params
33
+ * @param {Object} params.competition The competition it belongs to.
34
+ * @param {Array<{round: string, count: number}>} params.waves Its
35
+ * rounds in play order, widest first.
36
+ * @param {?number} [params.divisionId]
37
+ * @param {?string} [params.divisionName]
38
+ * @param {?string} [params.divisionGender]
39
+ * @param {number} [params.level] 0 is the strongest field.
40
+ * @param {boolean} [params.isPro] Pro draws use the pro courts only.
41
+ */
42
+ constructor({
43
+ competition, waves, divisionId = null, divisionName = null,
44
+ divisionGender = null, level = 0, isPro = false,
45
+ }) {
46
+ this.competition = competition
47
+ this.waves = waves
48
+ this.divisionId = divisionId
49
+ this.divisionName = divisionName
50
+ this.divisionGender = divisionGender
51
+ this.level = level
52
+ this.isPro = isPro
53
+
54
+ this.roundIndex = 0
55
+ this.placedInRound = 0
56
+ this.placed = 0
57
+ this.roundCourts = null
58
+ this.lastParts = 1
59
+ this.freeFrom = -Infinity
60
+ }
61
+
62
+ /** Whether every round of the draw has been planned. */
63
+ get done() {
64
+ return this.roundIndex >= this.waves.length
65
+ }
66
+
67
+ /** The round being planned, or null once the draw is done. */
68
+ get round() {
69
+ return this.waves[this.roundIndex] ?? null
70
+ }
71
+
72
+ /** How many matches the current round holds in total. */
73
+ get width() {
74
+ return this.round?.count ?? 0
75
+ }
76
+
77
+ /** How many of them are still to be booked. */
78
+ get remaining() {
79
+ return this.width - this.placedInRound
80
+ }
81
+
82
+ /** Whether the current round is part way through. */
83
+ get underway() {
84
+ return this.placedInRound > 0
85
+ }
86
+
87
+ /**
88
+ * How many rounds are left after this one - 0 is the Final, 1 the
89
+ * semi. Doubles as "how important is this match" wherever the lower
90
+ * courts are handed out.
91
+ */
92
+ get roundsRemaining() {
93
+ return this.waves.length - 1 - this.roundIndex
94
+ }
95
+
96
+ /**
97
+ * Matches of the whole draw that never found a court - what it is
98
+ * short by, for the plan to report.
99
+ */
100
+ get unplanned() {
101
+ const total = this.waves.reduce((sum, wave) => sum + wave.count, 0)
102
+
103
+ return Math.max(0, total - this.placed)
104
+ }
105
+
106
+ /**
107
+ * Whether this draw may go on in this slot: it has a round left and
108
+ * has had whatever rest it is owed.
109
+ *
110
+ * @param {number} slotIndex
111
+ * @returns {boolean}
112
+ */
113
+ readyAt(slotIndex) {
114
+ return !this.done && slotIndex >= this.freeFrom
115
+ }
116
+
117
+ /**
118
+ * The cuts the current round could be played in, fewest first.
119
+ *
120
+ * A round goes on whole where the room is there and is otherwise cut
121
+ * the way the bracket cuts. Halving is always available, however
122
+ * narrow it leaves the parts; quarters and eighths need parts that
123
+ * still hold matches of their own.
124
+ *
125
+ * @param {boolean} split Whether cutting is allowed at all.
126
+ * @returns {Array<{parts: number, count: number}>} Courts needed
127
+ * (count) for each number of parts.
128
+ */
129
+ cuts(split) {
130
+ const options = []
131
+
132
+ for (const parts of SPLIT_SIZES) {
133
+ if (parts > 1 && (!split || this.width % parts !== 0)) {
134
+ continue
135
+ }
136
+
137
+ const count = parts === 1 ? this.width : this.width / parts
138
+
139
+ if (parts > 2 && count < MIN_ROUND_PART) {
140
+ continue
141
+ }
142
+
143
+ options.push({ parts, count })
144
+ }
145
+
146
+ return options
147
+ }
148
+
149
+ /**
150
+ * Whether taking this cut would abandon a split the draw is already
151
+ * in. Its second half is a round behind its first all the way down,
152
+ * and going back to whole rounds would put the two halves on court
153
+ * together. The Final ends it: a semi split 1/1 still leads to one
154
+ * Final.
155
+ *
156
+ * @param {number} parts
157
+ * @returns {boolean}
158
+ */
159
+ breaksStride(parts) {
160
+ return this.lastParts > 1
161
+ && this.round?.round !== 'F'
162
+ && parts !== this.lastParts
163
+ }
164
+
165
+ /**
166
+ * Commits the round to these courts for as long as it runs. Whatever
167
+ * it starts on it finishes on - re-picking each slot is what had a
168
+ * round open on C10/C11 and close on C14/C15 an hour later.
169
+ *
170
+ * A round too wide for the day to finish pins nothing: there is no
171
+ * set of courts it could finish on, and pinning would only keep it
172
+ * off the ones that free up later.
173
+ *
174
+ * @param {Array} courts
175
+ */
176
+ pin(courts) {
177
+ this.roundCourts = this.roundCourts ?? courts.map(court => court.id)
178
+ }
179
+
180
+ /**
181
+ * Records matches booked, and says when the draw is next free.
182
+ *
183
+ * A split is its own rest: the half that played first has sat out
184
+ * every band the second half took, so no rest slot is charged on top
185
+ * of it.
186
+ *
187
+ * @param {Object} params
188
+ * @param {number} params.count How many matches were just booked.
189
+ * @param {number} params.parts The cut they were booked in.
190
+ * @param {number} params.slotIndex Where the cut started.
191
+ * @param {number} params.restSlots Slots owed after a whole round.
192
+ */
193
+ book({ count, parts, slotIndex, restSlots }) {
194
+ this.placed += count
195
+
196
+ if (this.placedInRound + count < this.width) {
197
+ this.placedInRound += count
198
+ this.freeFrom = slotIndex + parts
199
+
200
+ return
201
+ }
202
+
203
+ this.lastParts = parts
204
+ this.roundIndex += 1
205
+ this.placedInRound = 0
206
+ this.roundCourts = null
207
+ this.freeFrom = slotIndex + parts + (parts > 1 ? 0 : restSlots)
208
+ }
209
+ }