@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,156 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * How much court time a knockout draw needs, round by round, worked
7
+ * out from the draw itself.
8
+ *
9
+ * Planning used to be handed the answer as a list of counts by the
10
+ * server. It is worked out here instead, for the same reason group
11
+ * capacity is (see auto-planner.mjs's own note on it): the backend
12
+ * returns and stores the draw, and how much court time that draw
13
+ * needs is a question about the draw. One component owns the answer
14
+ * and the two can no longer disagree.
15
+ *
16
+ * A bye is the whole reason this is not just "half the field, then
17
+ * half again": a row with one side filled and the other empty
18
+ * advances a team without a game being played, so it needs no court
19
+ * time at all.
20
+ */
21
+
22
+ /**
23
+ * Bracket rounds in play order, biggest field first - the same labels
24
+ * a saved bracket's rows carry.
25
+ */
26
+ export const ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
27
+
28
+ /**
29
+ * The rounds of a bracket that has actually been drawn and saved.
30
+ *
31
+ * A round's name comes off its own saved rows, never from how many
32
+ * matches it holds: a bye-shrunk R16 (4 real matches once half its
33
+ * field has a bye) lands on the exact same count as a genuine QF, so
34
+ * a count can never say which round it is.
35
+ *
36
+ * @param {Array<{round: string, home: boolean, visitor: boolean}>} matches
37
+ * @returns {Array<{round: string, count: number}>}
38
+ */
39
+ export function wavesFromSavedMatches(matches) {
40
+ const rounds = new Set()
41
+ const real = new Map()
42
+
43
+ for (const match of matches) {
44
+ rounds.add(match.round)
45
+
46
+ // Both sides filled: a real match. Both still empty: a TBD row
47
+ // waiting on winners, which will need its slot once they are
48
+ // known. Exactly one side filled: a bye, and no court time.
49
+ if (match.home === match.visitor) {
50
+ real.set(match.round, (real.get(match.round) ?? 0) + 1)
51
+ }
52
+ }
53
+
54
+ return ROUND_ORDER
55
+ .filter(round => rounds.has(round))
56
+ .map(round => ({ round, count: real.get(round) ?? 0 }))
57
+ }
58
+
59
+ /**
60
+ * The rounds a draw of this many entrants will need, before one has
61
+ * been drawn.
62
+ *
63
+ * The draw is the next power of two at or above the entrant count, and
64
+ * the gap is byes. Only the first round is short of half the draw -
65
+ * every team with a bye is through to round two without playing, so
66
+ * round two onward is the plain halving sequence.
67
+ *
68
+ * @param {number} entrants
69
+ * @returns {Array<{round: string, count: number}>}
70
+ */
71
+ export function wavesForEntrants(entrants) {
72
+ if (entrants <= 1) {
73
+ return []
74
+ }
75
+
76
+ const half = nextPowerOfTwo(entrants) / 2
77
+ const counts = []
78
+
79
+ for (let matches = half; matches >= 1; matches /= 2) {
80
+ counts.push(matches)
81
+ }
82
+
83
+ // Round one, minus the byes.
84
+ counts[0] = entrants - half
85
+
86
+ // A draw that has not been drawn yet is exactly the canonical
87
+ // halving sequence by construction, so here - unlike a saved
88
+ // bracket's possibly bye-shrunk rounds - a round's name can be read
89
+ // off its position. Both lists end at the Final, so a count's name
90
+ // is whichever ROUND_ORDER entry sits the same distance from the end.
91
+ const offset = ROUND_ORDER.length - counts.length
92
+
93
+ return counts.map((count, index) => ({ round: ROUND_ORDER[offset + index], count }))
94
+ }
95
+
96
+ /**
97
+ * A division's knockout rounds: off its saved bracket where it has
98
+ * one, and off its roster where it does not.
99
+ *
100
+ * Once a real bracket exists, the estimate gets out of the way rather
101
+ * than keep reserving capacity for a shape that is not what was saved.
102
+ *
103
+ * @param {Object} division
104
+ * @param {Array<{round: string, home: boolean, visitor: boolean}>} [division.knockout_matches]
105
+ * @param {number} [division.team_count] Eligible teams on its roster.
106
+ * @param {number} [division.group_size] Teams to a group.
107
+ * @param {number} [division.advance_per_group] How many each group sends on.
108
+ * @param {Object} [options]
109
+ * @param {boolean} [options.itf] An ITF division plays no group stage:
110
+ * every eligible team enters the draw directly.
111
+ * @returns {Array<{round: string, count: number}>}
112
+ */
113
+ export function wavesForDivision(division, { itf = false } = {}) {
114
+ const saved = division.knockout_matches ?? []
115
+
116
+ if (saved.length > 0) {
117
+ return wavesFromSavedMatches(saved)
118
+ }
119
+
120
+ return wavesForEntrants(entrantsFor(division, { itf }))
121
+ }
122
+
123
+ /**
124
+ * How many teams reach the draw: every eligible team for an ITF
125
+ * division, and however many its groups send on for anyone else.
126
+ */
127
+ export function entrantsFor(division, { itf = false } = {}) {
128
+ const teams = division.team_count ?? 0
129
+
130
+ if (teams === 0) {
131
+ return 0
132
+ }
133
+
134
+ if (itf) {
135
+ return teams
136
+ }
137
+
138
+ const groupSize = division.group_size ?? 0
139
+ const advancing = division.advance_per_group ?? 0
140
+
141
+ if (groupSize <= 0 || advancing <= 0) {
142
+ return 0
143
+ }
144
+
145
+ return Math.ceil(teams / groupSize) * advancing
146
+ }
147
+
148
+ function nextPowerOfTwo(n) {
149
+ let value = 1
150
+
151
+ while (value < n) {
152
+ value *= 2
153
+ }
154
+
155
+ return value
156
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * A slot needs a lit court when the match played in it ENDS after
7
+ * dark, not when it starts after some fixed clock time. That
8
+ * distinction is what keeps the rule general across competitions: a
9
+ * group can only use an unlit court if its whole section finishes
10
+ * before dark, so with 45-minute slots and dark at 18:00 a 5-team
11
+ * competition (5-match, 3h45 sections) runs out of unlit capacity at
12
+ * 15:30 while a 4-team one (3-match, 2h15 sections) keeps going until
13
+ * 17:00 - same courts, same lights, different boundary, both derived
14
+ * rather than configured.
15
+ *
16
+ * The backend sends raw times only and passes dark-from as an
17
+ * attribute on the element; deciding what that means for a court is a
18
+ * scheduling decision and belongs client-side (spec.md).
19
+ *
20
+ * @param {{slots: Array<{end_minutes?: number}>}} state
21
+ * @param {string} [darkFromTime] "HH:MM"
22
+ * @returns {Object} the same state, its slots carrying lit_required
23
+ */
24
+ export function withLitRequired(state, darkFromTime) {
25
+ if (!state?.slots) {
26
+ return state
27
+ }
28
+
29
+ const darkFrom = toMinutes(darkFromTime)
30
+
31
+ return {
32
+ ...state,
33
+ slots: state.slots.map(slot => ({
34
+ ...slot,
35
+ lit_required: darkFrom == null || slot.end_minutes == null
36
+ ? slot.lit_required ?? false
37
+ : slot.end_minutes > darkFrom,
38
+ })),
39
+ }
40
+ }
41
+
42
+ function toMinutes(time) {
43
+ if (typeof time !== 'string') {
44
+ return null
45
+ }
46
+
47
+ const [hours, minutes] = time.split(':').map(Number)
48
+
49
+ return Number.isFinite(hours) ? hours * 60 + (Number.isFinite(minutes) ? minutes : 0) : null
50
+ }
@@ -0,0 +1,13 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * How long a match takes, and how long a team is owed between two of
7
+ * its own - the two figures every planner shape measures its slots
8
+ * against. Shared so the group side and the knockout side cannot drift
9
+ * apart on what a match costs.
10
+ */
11
+ export const MATCH_MINUTES = 45
12
+
13
+ export const REST_MINUTES = 45
@@ -0,0 +1,74 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * Which courts are busy, and when.
7
+ *
8
+ * Held as real time spans rather than cells of a grid: competitions do
9
+ * not share a clock - one runs 45-minute matches from 08:00, another
10
+ * 90-minute ones from 09:00 - and no single grid holds both. The only
11
+ * question worth asking is whether a court is already occupied for the
12
+ * stretch being asked about.
13
+ *
14
+ * Shared by both planners so neither can double-book what the other
15
+ * has taken.
16
+ */
17
+ export function makeOccupancy() {
18
+ const spans = new Map()
19
+
20
+ const key = (date, courtId) => `${date}|${courtId}`
21
+
22
+ return {
23
+ free(date, courtId, start, end) {
24
+ const taken = spans.get(key(date, courtId))
25
+
26
+ return !taken?.some(([from, to]) => start < to && from < end)
27
+ },
28
+
29
+ /**
30
+ * Whether a court is free for every one of these slots.
31
+ */
32
+ freeFor(date, courtId, slots) {
33
+ return slots.every(slot => this.free(date, courtId, slot.start_minutes, slot.end_minutes))
34
+ },
35
+
36
+ take(date, courtId, start, end) {
37
+ const id = key(date, courtId)
38
+
39
+ if (!spans.has(id)) {
40
+ spans.set(id, [])
41
+ }
42
+
43
+ spans.get(id).push([start, end])
44
+ },
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Whether a court can host a stretch of play.
50
+ *
51
+ * Lighting is a question about the whole stretch, not the individual
52
+ * match: a run reaching past dark needs a lit court from its first
53
+ * match, because everyone on court at the same time plays in the same
54
+ * conditions.
55
+ */
56
+ export function courtCanHost(court, slots, darkFromMinutes) {
57
+ if (!court.active) {
58
+ return false
59
+ }
60
+
61
+ // A court a director has opened up for the day only from a given
62
+ // time onward (auto-planner.mjs's openCourtsFor) - baked onto the
63
+ // court object itself, so this needs no date of its own to enforce.
64
+ if (court.availableFromMinutes != null
65
+ && slots.some(slot => slot.start_minutes < court.availableFromMinutes)) {
66
+ return false
67
+ }
68
+
69
+ if (darkFromMinutes == null || court.lit) {
70
+ return true
71
+ }
72
+
73
+ return slots.every(slot => slot.end_minutes <= darkFromMinutes)
74
+ }
@@ -0,0 +1,236 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { courtCanHost } from './occupancy.mjs'
6
+
7
+ /**
8
+ * Plans any group shape whose round robin needs more than one court at
9
+ * once, courtsPerRound of them, all bundled groups taking their turn
10
+ * on the same set before handing it to the next: 4-team's three rounds
11
+ * of two matches is the shape this was written for, but the same
12
+ * mechanism gives a 3-team group (courtsPerRound 1 - it never needs a
13
+ * second court, but still bundles with another 3-team group so each
14
+ * gets a rest between its own matches instead of playing straight
15
+ * through alone) the same courts, the same rest, the same priority
16
+ * order, as 4-team - see three-team-planner.mjs.
17
+ *
18
+ * courtsPerRound 2 (4-team): courtsPerRound 1 (3-team):
19
+ * 1: A v B C v D 1: A v B
20
+ * 2: A v B C v D 1: C v D
21
+ * 1: A v C B v D 2: A v B
22
+ * ... 2: C v D
23
+ * ...
24
+ *
25
+ * That waiting is the whole reason to bundle: alone, a group's own
26
+ * round robin runs its matches back to back with no rest between a
27
+ * team's own appearances. Bundled with another group of the same
28
+ * shape, they take turns - one court (or courtsPerRound of them) a
29
+ * turn - so a team gets the other group's turn to rest through.
30
+ *
31
+ * TODO: 4-team and 5-team should both be able to choose courtsPerRound
32
+ * explicitly (a division opting into "pairs" plays its round robin
33
+ * across 2 courts for the rest that gives; opting out runs the whole
34
+ * thing on one, the run-queue-planner.mjs way) rather than 4-team
35
+ * being hardcoded to 2 and 5-team's engine never touching this module
36
+ * at all. Not built yet - flagged here so the two do not drift apart
37
+ * when it is.
38
+ */
39
+
40
+ /**
41
+ * Lays out a set of same-shape groups sharing courtsPerRound courts at
42
+ * once.
43
+ *
44
+ * Groups take turns: every group's first round, then every group's
45
+ * second, and so on - nothing sits idle, and a group waits out as many
46
+ * slots between its own rounds as there are other groups in the unit.
47
+ *
48
+ * @param {Array<{id: number, teamIds: number[]}>} groups
49
+ * @param {Object} params
50
+ * @param {Array<{slot: number, pairs: number[][]}>} params.template The
51
+ * group shape's own round robin, one entry per round - see
52
+ * group-schedule.mjs's GROUP_TEMPLATES and four-team-planner.mjs's
53
+ * FOUR_TEAM_TEMPLATE. A round's pairs.length is courtsPerRound.
54
+ * @param {(group: {id: number, teamIds: number[]}) => Array<{slot: number, pairIndex: number, index: number}>} params.matchesFor
55
+ * A group's own matches, flattened and indexed in play order.
56
+ * @returns {Array<Array<{groupId: number, index: number}|null>>} slot by court
57
+ */
58
+ export function unitLayout(groups, { template, matchesFor }) {
59
+ const slots = []
60
+
61
+ for (const round of template) {
62
+ // What each group actually plays this round: courtsPerRound
63
+ // matches, or fewer where a vacant seat leaves one empty.
64
+ const turns = groups
65
+ .map(group => {
66
+ const matches = matchesFor(group)
67
+
68
+ return round.pairs
69
+ .map((pair, pairIndex) => {
70
+ const index = matches.findIndex(
71
+ match => match.slot === round.slot && match.pairIndex === pairIndex
72
+ )
73
+
74
+ return index === -1 ? null : { groupId: group.id, index }
75
+ })
76
+ .filter(Boolean)
77
+ })
78
+ .filter(turn => turn.length > 0)
79
+
80
+ // One turn to a slot, so groups take it in turns rather than all
81
+ // at once - the rest the bundling exists for.
82
+ for (const turn of turns) {
83
+ slots.push(round.pairs.map((_, courtIndex) => turn[courtIndex] ?? null))
84
+ }
85
+ }
86
+
87
+ return slots
88
+ }
89
+
90
+ /**
91
+ * Places an already-computed layout (see unitLayout) onto the lowest
92
+ * stretch of courts that is actually free, and the earliest start on
93
+ * it - no bands, no reservation.
94
+ *
95
+ * Split out from placeUnit so a caller that needs to place only part
96
+ * of a unit's layout - auto-planner.mjs cuts a unit's sequence in half
97
+ * across two days when its dates span two of them - can reuse this
98
+ * search directly instead of reimplementing it.
99
+ *
100
+ * A unit only ever needs as many courts as some row of the layout
101
+ * actually uses - a unit of nothing but byes never has two matches at
102
+ * once, and searching for courtsPerRound of them would strand a
103
+ * perfectly usable neighbouring court idle beside every one of its
104
+ * slots for no reason.
105
+ *
106
+ * @param {Array<Array<{groupId: number, index: number}|null>>} layout
107
+ * @param {(groupId: number) => {id, competitionId, divisionId, divisionName, divisionGender, groupName}} lookup
108
+ */
109
+ export function placeLayout({ layout, lookup, courts, slots, date, occupancy, darkFrom, blocks }) {
110
+ if (layout.length === 0) {
111
+ return false
112
+ }
113
+
114
+ const width = Math.max(1, ...layout.map(row => row.filter(Boolean).length))
115
+
116
+ for (let start = 0; start + layout.length <= slots.length; start++) {
117
+ const window = slots.slice(start, start + layout.length)
118
+
119
+ // Neighbours among the courts that can actually host this window,
120
+ // not among the courts as they are numbered. An unlit court in the
121
+ // middle of the row would otherwise strand the court beside it: a
122
+ // pair needs two, and if one of them can never host the run the
123
+ // other is never offered to anybody.
124
+ const eligible = courts.filter(court => courtCanHost(court, window, darkFrom))
125
+
126
+ for (let index = 0; index + width <= eligible.length; index++) {
127
+ const set = eligible.slice(index, index + width)
128
+
129
+ const usable = set.every(court =>
130
+ occupancy.freeFor(date, court.id, window)
131
+ )
132
+
133
+ if (!usable) {
134
+ continue
135
+ }
136
+
137
+ layout.forEach((row, offset) => {
138
+ row.forEach((cell, courtIndex) => {
139
+ if (!cell) {
140
+ return
141
+ }
142
+
143
+ const group = lookup(cell.groupId)
144
+ const court = set[courtIndex]
145
+ const slot = window[offset]
146
+
147
+ blocks.push({
148
+ competition_id: group.competitionId,
149
+ division_id: group.divisionId ?? null,
150
+ division_name: group.divisionName ?? null,
151
+ division_gender: group.divisionGender ?? null,
152
+ group_id: group.id,
153
+ group_name: group.groupName ?? null,
154
+ phase: 'group',
155
+ round: 'group',
156
+ match_slot: cell.index,
157
+ court_id: court.id,
158
+ date,
159
+ slot: slot.key,
160
+ short_group: true,
161
+ })
162
+
163
+ occupancy.take(date, court.id, slot.start_minutes, slot.end_minutes)
164
+ })
165
+ })
166
+
167
+ return true
168
+ }
169
+ }
170
+
171
+ return false
172
+ }
173
+
174
+ /**
175
+ * How a set of same-shape groups is split into units sharing courts.
176
+ *
177
+ * Units of two are the default: a shorter window drops into the light
178
+ * where a unit of three will not. What decides the split is parity
179
+ * rather than greed - a group left over on its own has nobody to share
180
+ * with, so an odd count takes exactly one unit of three and pairs off
181
+ * the rest.
182
+ *
183
+ * 7 groups -> 3, 2, 2 9 groups -> 3, 2, 2, 2
184
+ *
185
+ * When courts are scarce the same no-leftovers rule is applied the
186
+ * other way round, taking as many threes as possible to hold fewer at
187
+ * once:
188
+ *
189
+ * 7 groups -> 3, 2, 2 9 groups -> 3, 3, 3
190
+ *
191
+ * @param {number} count
192
+ * @param {{dense?: boolean}} options
193
+ * @returns {number[]} unit sizes, largest first
194
+ */
195
+ export function unitSizes(count, { dense = false } = {}) {
196
+ if (count < 2) {
197
+ // Nothing to share with. The caller decides what to do with it
198
+ // rather than being handed a unit of one.
199
+ return []
200
+ }
201
+
202
+ if (!dense) {
203
+ const threes = count % 2 === 0 ? 0 : 1
204
+ return sizes(threes, (count - threes * 3) / 2)
205
+ }
206
+
207
+ // As many threes as fit without stranding a single group: a
208
+ // remainder of one means dropping back a three to leave four, which
209
+ // pairs off.
210
+ let threes = Math.floor(count / 3)
211
+
212
+ if (count - threes * 3 === 1) {
213
+ threes -= 1
214
+ }
215
+
216
+ return sizes(threes, (count - threes * 3) / 2)
217
+ }
218
+
219
+ function sizes(threes, twos) {
220
+ return [...Array(threes).fill(3), ...Array(twos).fill(2)]
221
+ }
222
+
223
+ /**
224
+ * Splits a set of same-shape groups into units sharing a court set.
225
+ */
226
+ export function buildUnits(groups, { dense = false } = {}) {
227
+ const units = []
228
+ let cursor = 0
229
+
230
+ for (const size of unitSizes(groups.length, { dense })) {
231
+ units.push(groups.slice(cursor, cursor + size))
232
+ cursor += size
233
+ }
234
+
235
+ return units
236
+ }