@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,603 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { bandDistance, computeSectionBands, sectionsFor } from './court-bands.mjs'
|
|
6
|
+
import { groupRuns } from './group-schedule.mjs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where a group should land the day after playing a given section:
|
|
10
|
+
* S1 to S3, S2 to S4, S3 to S1, S4 to S2 - prio.pl's rotation. A
|
|
11
|
+
* group that played late one day plays early the next and the other
|
|
12
|
+
* way round, so nobody gets two late finishes or two early starts.
|
|
13
|
+
*/
|
|
14
|
+
const SECTION_OFFSET = { 0: 2, 1: 3, 2: 0, 3: 1 }
|
|
15
|
+
|
|
16
|
+
// Mirrors GameCounter::GAMES_PER_GROUP server-side: how many matches a
|
|
17
|
+
// full group of this size plays. Used only to report a group with no
|
|
18
|
+
// known match template (see enqueue()'s own guard below) against the
|
|
19
|
+
// same "assume everyone showed up" baseline games_needed itself uses.
|
|
20
|
+
const GAMES_PER_POOL_SIZE = { 3: 3, 4: 6, 5: 10 }
|
|
21
|
+
|
|
22
|
+
const queueKey = (competitionId, date) => `${competitionId}:${date}`
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Group play for any shape whose groups run their whole round robin as
|
|
26
|
+
* one unbroken stretch on one court - 5-team's ten matches over two
|
|
27
|
+
* days, 3-team's three matches in one - as opposed to a 4-team group,
|
|
28
|
+
* which needs a *pair* of courts running two matches at once each
|
|
29
|
+
* round.
|
|
30
|
+
*
|
|
31
|
+
* The unit of allocation is that stretch - a run - not a match, and
|
|
32
|
+
* the day divides into as many sections as whole runs fit in it.
|
|
33
|
+
* prio.pl calls them S1 to S4; they are not clock times but the run
|
|
34
|
+
* itself, so five 45-minute matches make a 3h45 section and a 3-team
|
|
35
|
+
* group's three matches make a shorter one.
|
|
36
|
+
*
|
|
37
|
+
* Two questions have to be answered for every court, and keeping them
|
|
38
|
+
* apart is what makes the result read properly: how many runs a
|
|
39
|
+
* division holds in a section - its quota, settled up front by
|
|
40
|
+
* prepare() - and which court each of those runs lands on, which is
|
|
41
|
+
* its band, a contiguous fraction of the court list.
|
|
42
|
+
*
|
|
43
|
+
* Courts, slots, lighting and the sections themselves belong to the
|
|
44
|
+
* caller. It hands in a court and a section and gets a run back.
|
|
45
|
+
*
|
|
46
|
+
* Short groups are none of this planner's business - they are pooled
|
|
47
|
+
* across divisions onto a shared court pair.
|
|
48
|
+
*
|
|
49
|
+
* Both five-team-planner.mjs and three-team-planner.mjs are thin
|
|
50
|
+
* re-exports of this same engine - the resource model (one run, one
|
|
51
|
+
* court, whole stretch) does not change between those two shapes,
|
|
52
|
+
* only the match template (group-schedule.mjs's GROUP_TEMPLATES)
|
|
53
|
+
* enqueue() reads off the group's own team count.
|
|
54
|
+
*/
|
|
55
|
+
export function makeRunQueuePlanner() {
|
|
56
|
+
const groupRunQueue = new Map()
|
|
57
|
+
const sectionRunsByDivision = new Map()
|
|
58
|
+
const bandFractionByDivision = new Map()
|
|
59
|
+
const sectionQuotaByDivision = new Map()
|
|
60
|
+
const sectionCountByDate = new Map()
|
|
61
|
+
const sectionsByGroup = new Map()
|
|
62
|
+
const groupsInSection = new Set()
|
|
63
|
+
const totalRunsByDivision = new Map()
|
|
64
|
+
|
|
65
|
+
const nightSections = new Set()
|
|
66
|
+
const runLengthByDay = new Map()
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How long one run is, in slots.
|
|
70
|
+
*
|
|
71
|
+
* Read off the runs themselves: a run is a stretch of consecutive
|
|
72
|
+
* matches on one court, so its own length is the answer. Taking one
|
|
73
|
+
* figure per competition instead meant the largest group size set
|
|
74
|
+
* the rhythm for every division in it, and a single 5-team draw made
|
|
75
|
+
* a competition of fours tick in fives.
|
|
76
|
+
*/
|
|
77
|
+
function runLengthFor(competitionId, date) {
|
|
78
|
+
// Remembered as the runs are queued, not counted off the queue:
|
|
79
|
+
// by the time the plan is reported the queue has been drained,
|
|
80
|
+
// and the answer would be that a run is one slot long.
|
|
81
|
+
return runLengthByDay.get(queueKey(competitionId, date)) ?? 1
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Queues a group's runs against the dates its competition plays.
|
|
86
|
+
*
|
|
87
|
+
* The groups arrive in division-priority order, so the strongest
|
|
88
|
+
* field claims the lowest courts, and a group's own runs stay in
|
|
89
|
+
* template order.
|
|
90
|
+
*
|
|
91
|
+
* @returns {Array<{date: string, count: number}>} the capacity the
|
|
92
|
+
* caller still has to account for, per date.
|
|
93
|
+
*/
|
|
94
|
+
function enqueue({ competition, group, dates, asFull = false }) {
|
|
95
|
+
const teams = group.team_ids ?? []
|
|
96
|
+
const size = group.pool_size ?? teams.length
|
|
97
|
+
|
|
98
|
+
// While registration is open a group is planned as the group it
|
|
99
|
+
// will be. A seat still to be filled needs the same court time as
|
|
100
|
+
// one already taken, and planning the four who have entered books
|
|
101
|
+
// a 4-team shape that the fifth then has nowhere to join. The
|
|
102
|
+
// spare cells are the point, not waste.
|
|
103
|
+
const seats = asFull && size > teams.length
|
|
104
|
+
? Array.from({ length: size }, (_, i) => teams[i] ?? -(i + 1))
|
|
105
|
+
: teams
|
|
106
|
+
|
|
107
|
+
const runs = groupRuns({ teamIds: seats, groupSize: size })
|
|
108
|
+
const needed = []
|
|
109
|
+
|
|
110
|
+
// The days this competition is really in its group phase, not the
|
|
111
|
+
// days its draw asked for. A last group date that is also the day
|
|
112
|
+
// it turns to knockout is not a day this can use.
|
|
113
|
+
const days = dates ?? competition.group_dates ?? []
|
|
114
|
+
|
|
115
|
+
// A real, registered group with no match template at all for its
|
|
116
|
+
// actual team count (group-schedule.mjs's templateFor only covers
|
|
117
|
+
// one vacant seat, not two or more - reachable here once
|
|
118
|
+
// registration closes and asFull stops padding a short group up to
|
|
119
|
+
// its division's full size) plays nothing, silently: without this,
|
|
120
|
+
// the caller never learns it needed any games at all, so it never
|
|
121
|
+
// reaches the shortfall total either. Reported against the same
|
|
122
|
+
// "assume everyone showed up" baseline games_needed itself uses,
|
|
123
|
+
// split evenly across the group's own days like a real template's
|
|
124
|
+
// day split would be.
|
|
125
|
+
if (runs.length === 0 && seats.length > 0 && days.length > 0) {
|
|
126
|
+
const total = GAMES_PER_POOL_SIZE[size] ?? (seats.length * (seats.length - 1)) / 2
|
|
127
|
+
const perDay = Math.floor(total / days.length)
|
|
128
|
+
let remainder = total - perDay * days.length
|
|
129
|
+
|
|
130
|
+
for (const date of days) {
|
|
131
|
+
const count = perDay + (remainder > 0 ? 1 : 0)
|
|
132
|
+
remainder -= 1
|
|
133
|
+
|
|
134
|
+
if (count > 0) {
|
|
135
|
+
needed.push({ date, count })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return needed
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const run of runs) {
|
|
143
|
+
// A template that spans two days needs two days to span. Where
|
|
144
|
+
// the competition has only one, the group plays its whole round
|
|
145
|
+
// robin that day rather than losing the half with nowhere to go
|
|
146
|
+
// - ten matches on one court, each of the five teams playing
|
|
147
|
+
// four of them. The 4-team units fold the same way when their
|
|
148
|
+
// second day turns out not to be a group day.
|
|
149
|
+
const date = days.length === 1 ? days[0] : days[run.day - 1]
|
|
150
|
+
|
|
151
|
+
if (!date) {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const key = queueKey(competition.id, date)
|
|
156
|
+
|
|
157
|
+
if (!groupRunQueue.has(key)) {
|
|
158
|
+
groupRunQueue.set(key, [])
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const divisionKey = `${date}:${group.division_id}`
|
|
162
|
+
totalRunsByDivision.set(divisionKey, (totalRunsByDivision.get(divisionKey) ?? 0) + 1)
|
|
163
|
+
|
|
164
|
+
groupRunQueue.get(key).push({
|
|
165
|
+
day: run.day,
|
|
166
|
+
isPro: group.is_pro === true,
|
|
167
|
+
groupId: group.id,
|
|
168
|
+
groupName: group.name,
|
|
169
|
+
divisionId: group.division_id,
|
|
170
|
+
divisionName: group.division_name,
|
|
171
|
+
divisionGender: group.division_gender,
|
|
172
|
+
indices: run.indices,
|
|
173
|
+
positions: run.positions,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
runLengthByDay.set(key, Math.max(runLengthByDay.get(key) ?? 1, run.positions.length))
|
|
177
|
+
|
|
178
|
+
needed.push({ date, count: run.positions.length })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return needed
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Bands and section quotas for every date that has runs queued.
|
|
186
|
+
*
|
|
187
|
+
* Worked out once, after every competition's runs are known, so they
|
|
188
|
+
* stay put as the queues drain. Reading them off the live queue made
|
|
189
|
+
* the bands drift as runs were consumed, and only the first section
|
|
190
|
+
* of the day came out in order.
|
|
191
|
+
*
|
|
192
|
+
* @param {Object} params
|
|
193
|
+
* @param {Function} params.slotsFor (competitionId, date) => the slots
|
|
194
|
+
* that competition actually plays that day. Sizing the sections off
|
|
195
|
+
* a shared list instead read lit_required from the server, which
|
|
196
|
+
* leaves it unset, so a night section was counted as holding every
|
|
197
|
+
* court when only the lit ones can host it.
|
|
198
|
+
* @param {Array} params.courts The courts these runs may use.
|
|
199
|
+
* @param {Function} params.usable (court, slot) => boolean
|
|
200
|
+
*/
|
|
201
|
+
function prepare({ slotsFor, courts, usable }) {
|
|
202
|
+
for (const [key, queue] of groupRunQueue) {
|
|
203
|
+
const date = key.slice(key.indexOf(':') + 1)
|
|
204
|
+
const order = []
|
|
205
|
+
|
|
206
|
+
for (const run of queue) {
|
|
207
|
+
if (!order.includes(run.divisionId)) {
|
|
208
|
+
order.push(run.divisionId)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// How many runs each division should hold in each section of this
|
|
213
|
+
// day, worked out up front rather than emerging from the order
|
|
214
|
+
// courts happen to be filled in.
|
|
215
|
+
//
|
|
216
|
+
// Balancing incrementally could not hold both properties at once:
|
|
217
|
+
// judging every court on the running share turned a section into a
|
|
218
|
+
// round robin and interleaved the divisions, while judging it on
|
|
219
|
+
// the band let whoever owned a band take every court in it until
|
|
220
|
+
// their queue ran dry - which emptied the beginners out of S3.
|
|
221
|
+
//
|
|
222
|
+
// Deciding the counts first separates the two questions. How many
|
|
223
|
+
// runs a division gets in a section is settled here; which court
|
|
224
|
+
// each one lands on is left to the band.
|
|
225
|
+
const competitionId = key.slice(0, key.indexOf(':'))
|
|
226
|
+
const runLength = runLengthFor(competitionId, date)
|
|
227
|
+
// Only a section that can hold a whole run counts. Rounding up
|
|
228
|
+
// invented a final section out of the few slots left over, and
|
|
229
|
+
// every run shared out to it was then unplaceable.
|
|
230
|
+
const own = slotsFor(competitionId, date)
|
|
231
|
+
|
|
232
|
+
// Sections are not the same size. S1 and S2 have every court; a
|
|
233
|
+
// night section has only the lit ones, and only those that can
|
|
234
|
+
// host a whole run without the light going partway through. A flat
|
|
235
|
+
// quota therefore under-fills the day sections, the fallback fires
|
|
236
|
+
// to use the spare courts, and the balance it was meant to keep is
|
|
237
|
+
// lost.
|
|
238
|
+
const sections = sectionsFor(own, runLength, { courts, usable })
|
|
239
|
+
const sectionCount = sections.length
|
|
240
|
+
sectionCountByDate.set(date, sectionCount)
|
|
241
|
+
|
|
242
|
+
const capacities = sections.map(section => section.capacity)
|
|
243
|
+
|
|
244
|
+
for (const section of sections) {
|
|
245
|
+
if (section.night) {
|
|
246
|
+
nightSections.add(`${key}:${section.index}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Capacity is shared out section by section, against what that
|
|
251
|
+
// section can hold. Slicing each division's own runs across the
|
|
252
|
+
// sections instead over-subscribed the small ones: a night section
|
|
253
|
+
// holding three runs was promising five between two divisions, so
|
|
254
|
+
// whichever was asked first took the lot and the other got none.
|
|
255
|
+
const divisions = order.filter(id => (totalRunsByDivision.get(`${date}:${id}`) ?? 0) > 0)
|
|
256
|
+
|
|
257
|
+
const totals = new Map(
|
|
258
|
+
divisions.map(id => [id, totalRunsByDivision.get(`${date}:${id}`) ?? 0])
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
// The quota/band math itself is shared with every other planner
|
|
262
|
+
// shape - see court-bands.mjs. Only the run-queue bookkeeping
|
|
263
|
+
// (sections, lights, offsets) stays local to this planner.
|
|
264
|
+
const { quotasBySection, bandsBySection } = computeSectionBands({
|
|
265
|
+
divisions,
|
|
266
|
+
totals,
|
|
267
|
+
capacities,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
for (let section = 0; section < sectionCount; section++) {
|
|
271
|
+
for (const id of divisions) {
|
|
272
|
+
sectionQuotaByDivision.set(`${date}:${section}:${id}`, quotasBySection[section].get(id))
|
|
273
|
+
bandFractionByDivision.set(`${date}:${section}:${id}`, bandsBySection[section].get(id))
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Night capacity is the scarce resource, and taking runs in strict
|
|
281
|
+
* division order let the top division drain it - the strongest field
|
|
282
|
+
* played every night game and three divisions played none at all.
|
|
283
|
+
*
|
|
284
|
+
* The next run goes to whichever division would end up with the
|
|
285
|
+
* smallest share of its own runs in this section - prio.pl's S1 to
|
|
286
|
+
* S4, which are not clock times but the run itself: a section is one
|
|
287
|
+
* group's whole run on one court, so five 45-minute matches make a
|
|
288
|
+
* 3h45 section and a 4-team group's three make a shorter one.
|
|
289
|
+
*
|
|
290
|
+
* Balancing per section spreads every division across the day rather
|
|
291
|
+
* than letting the first division fill the morning and the last take
|
|
292
|
+
* whatever is left. Weighting by share rather than by count keeps it
|
|
293
|
+
* proportional: half the games means half the runs in each section.
|
|
294
|
+
*
|
|
295
|
+
* The +0.5 divisor is deliberate. Comparing the share a division
|
|
296
|
+
* already holds lets a small division jump the queue every time and
|
|
297
|
+
* finish over its proportion; plain +1 biases the other way, toward
|
|
298
|
+
* the large ones.
|
|
299
|
+
*
|
|
300
|
+
* Ties fall to the queue order, which is division priority, so the
|
|
301
|
+
* strongest field takes the lowest-numbered courts.
|
|
302
|
+
*/
|
|
303
|
+
function sectionCountFor(date) {
|
|
304
|
+
return sectionCountByDate.get(date) ?? 1
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function pick(competitionId, date, sectionIndex, court, { proCourt = false } = {}) {
|
|
308
|
+
const queue = groupRunQueue.get(queueKey(competitionId, date))
|
|
309
|
+
|
|
310
|
+
if (!queue || queue.length === 0) {
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A pro court takes a pro division's run and nothing else, and a
|
|
315
|
+
// pro run waits for one rather than taking an open court. Singles
|
|
316
|
+
// carries both kinds, so asking the competition put the beginners
|
|
317
|
+
// on the pro courts and left the pro draws queueing behind them.
|
|
318
|
+
const belongs = run => (run.isPro === true) === proCourt
|
|
319
|
+
|
|
320
|
+
if (!queue.some(belongs)) {
|
|
321
|
+
return { blocked: true }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Where along the courts this one sits, so a run can be judged on
|
|
325
|
+
// how well it belongs here.
|
|
326
|
+
const position = court && court.courtCount > 0
|
|
327
|
+
? court.courtPosition / court.courtCount
|
|
328
|
+
: 0
|
|
329
|
+
|
|
330
|
+
const scored = queue.map(run => {
|
|
331
|
+
const key = `${date}:${sectionIndex}:${run.divisionId}`
|
|
332
|
+
const used = sectionRunsByDivision.get(key) ?? 0
|
|
333
|
+
const total = totalRunsByDivision.get(`${date}:${run.divisionId}`) ?? 1
|
|
334
|
+
|
|
335
|
+
// The share this division would hold after taking the run, not
|
|
336
|
+
// the one it holds now. Comparing current shares lets a small
|
|
337
|
+
// division jump the queue on every pick and finish over its
|
|
338
|
+
// proportion - a division with half the games took six evenings
|
|
339
|
+
// where five was its share.
|
|
340
|
+
const share = (used + 0.5) / total
|
|
341
|
+
|
|
342
|
+
// Whether this court falls inside the division's own band, and
|
|
343
|
+
// if not how far outside. A band is an interval, not a point:
|
|
344
|
+
// measuring from its start alone hands the wider division too
|
|
345
|
+
// few courts, since every court is near some band's start.
|
|
346
|
+
const band = bandFractionByDivision.get(`${date}:${sectionIndex}:${run.divisionId}`)
|
|
347
|
+
?? { start: 0, end: 1 }
|
|
348
|
+
const distance = bandDistance(position, band)
|
|
349
|
+
|
|
350
|
+
return { share, step: 1 / total, distance }
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Share decides who is owed a run, the band decides where it goes.
|
|
354
|
+
//
|
|
355
|
+
// Neither alone works. Sorting on share first turns the section
|
|
356
|
+
// into a round robin and interleaves the divisions across the
|
|
357
|
+
// courts. Sorting on the band first lets whoever owns a band take
|
|
358
|
+
// every court in it until their queue runs dry, which emptied the
|
|
359
|
+
// beginners out of S3 entirely.
|
|
360
|
+
//
|
|
361
|
+
// The tolerance is one run's worth of share, so a division that
|
|
362
|
+
// has just taken a court is still owed enough to take the one next
|
|
363
|
+
// to it, and its groups stay together without it running away with
|
|
364
|
+
// the section.
|
|
365
|
+
// The quota decides who is owed a run in this section; the band
|
|
366
|
+
// decides which court it lands on. A division that has filled its
|
|
367
|
+
// quota here stands aside, so nobody runs away with a section, and
|
|
368
|
+
// among those still owed a run the court goes to whichever
|
|
369
|
+
// division's band covers it.
|
|
370
|
+
const indexed = scored
|
|
371
|
+
.map((entry, index) => ({ ...entry, index }))
|
|
372
|
+
.filter(entry => belongs(queue[entry.index]))
|
|
373
|
+
|
|
374
|
+
// A group that played under lights yesterday is not asked to do it
|
|
375
|
+
// again. This one is a rule rather than a preference: back-to-back
|
|
376
|
+
// late finishes are what the rotation exists to prevent.
|
|
377
|
+
const eligible = night(competitionId, date, sectionIndex)
|
|
378
|
+
? indexed.filter(entry => !playedUnderLights(queue[entry.index].groupId, date))
|
|
379
|
+
: indexed
|
|
380
|
+
|
|
381
|
+
// If every run left belongs to a group that played under lights
|
|
382
|
+
// yesterday, this court goes unused. Leaving a lit court idle is
|
|
383
|
+
// the lesser harm: the rotation exists precisely to stop the same
|
|
384
|
+
// group finishing late twice running. Distinct from a null return,
|
|
385
|
+
// which means there is nothing left to place at all.
|
|
386
|
+
if (eligible.length === 0) {
|
|
387
|
+
return { blocked: true }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// A group plays one run at a time. Where one group day holds a
|
|
391
|
+
// whole two-day template both halves sit in the same queue, and
|
|
392
|
+
// without this the second was handed the court beside the first -
|
|
393
|
+
// five teams playing ten matches two at a time, every one of them
|
|
394
|
+
// double-booked.
|
|
395
|
+
const free = eligible.filter(
|
|
396
|
+
entry => !groupsInSection.has(`${date}:${sectionIndex}:${queue[entry.index].groupId}`)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if (free.length === 0) {
|
|
400
|
+
return { blocked: true }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Nor does any group start its second run until every group has
|
|
404
|
+
// had its first, or one would take the day while another sat out.
|
|
405
|
+
const earliest = Math.min(...free.map(entry => queue[entry.index].day ?? 1))
|
|
406
|
+
const pool = free.filter(entry => (queue[entry.index].day ?? 1) === earliest)
|
|
407
|
+
|
|
408
|
+
const owed = pool.filter(entry => {
|
|
409
|
+
const key = `${date}:${sectionIndex}:${queue[entry.index].divisionId}`
|
|
410
|
+
const quota = sectionQuotaByDivision.get(key) ?? Infinity
|
|
411
|
+
|
|
412
|
+
return (sectionRunsByDivision.get(key) ?? 0) < quota
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// Rounding, or a section with more room than its quotas asked for,
|
|
416
|
+
// can leave nobody owed. The run still has to go somewhere rather
|
|
417
|
+
// than leave a court idle - but the spare capacity goes to whoever
|
|
418
|
+
// is furthest behind overall, not to whichever division's band
|
|
419
|
+
// happens to cover the leftover courts. Sorting the overflow by
|
|
420
|
+
// band handed every spare court in S1 to one division and emptied
|
|
421
|
+
// it out of S2.
|
|
422
|
+
// Of those still owed a run here, prefer the groups this section
|
|
423
|
+
// is the offset of - S3 wants yesterday's S1, S1 wants yesterday's
|
|
424
|
+
// S3. Guidance rather than a rule: there is rarely room to honour
|
|
425
|
+
// it for every group, so it only orders the choice.
|
|
426
|
+
const wanted = owed.filter(entry => {
|
|
427
|
+
const previous = previousSection(queue[entry.index].groupId, date)
|
|
428
|
+
return previous !== null && SECTION_OFFSET[previous] === sectionIndex
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// Failing the exact rotation, anything but the same section again.
|
|
432
|
+
// A group's offset target is often a night section with no room
|
|
433
|
+
// left, and without this it simply stayed where it was - half the
|
|
434
|
+
// groups repeating S2 two days running.
|
|
435
|
+
const moved = owed.filter(entry => {
|
|
436
|
+
const previous = previousSection(queue[entry.index].groupId, date)
|
|
437
|
+
return previous === null || previous !== sectionIndex
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
const wantedSet = new Set(wanted)
|
|
441
|
+
const movedSet = new Set(moved)
|
|
442
|
+
|
|
443
|
+
const best = owed.length > 0
|
|
444
|
+
? owed.reduce((a, b) => {
|
|
445
|
+
// The band comes first, always - which court a division holds
|
|
446
|
+
// is what keeps its groups together. The offset only chooses
|
|
447
|
+
// between two divisions that fit this court equally well; it
|
|
448
|
+
// never pulls a division away from a court its own band
|
|
449
|
+
// already covers in favour of one merely due its rotation.
|
|
450
|
+
// Treating "wanted" as a hard filter did exactly that: a
|
|
451
|
+
// division sitting at distance 0 lost the court to one at
|
|
452
|
+
// distance 0.121 purely because the latter was due its S3-to-
|
|
453
|
+
// S1 offset, splitting the first division's band in two.
|
|
454
|
+
if (b.distance !== a.distance) {
|
|
455
|
+
return b.distance < a.distance ? b : a
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const aWanted = wantedSet.has(a)
|
|
459
|
+
const bWanted = wantedSet.has(b)
|
|
460
|
+
|
|
461
|
+
if (aWanted !== bWanted) {
|
|
462
|
+
return bWanted ? b : a
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const aMoved = movedSet.has(a)
|
|
466
|
+
const bMoved = movedSet.has(b)
|
|
467
|
+
|
|
468
|
+
if (aMoved !== bMoved) {
|
|
469
|
+
return bMoved ? b : a
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return b.share < a.share ? b : a
|
|
473
|
+
})
|
|
474
|
+
: (() => {
|
|
475
|
+
// Spare capacity, beyond what the quotas asked for. It still
|
|
476
|
+
// must not eat a division's later sections: one with four runs
|
|
477
|
+
// and four sections has exactly one for each, and taking two
|
|
478
|
+
// here leaves it absent from the last.
|
|
479
|
+
// Spare capacity, beyond what the quotas asked for. A
|
|
480
|
+
// division may take it only if it will still have a run left
|
|
481
|
+
// for every section after this one - otherwise an extra court
|
|
482
|
+
// now costs it its place later in the day.
|
|
483
|
+
const sectionsLeft = Math.max(0, sectionCountFor(date) - sectionIndex - 1)
|
|
484
|
+
|
|
485
|
+
const canSpare = pool.filter(entry => {
|
|
486
|
+
const division = queue[entry.index].divisionId
|
|
487
|
+
const queued = queue.filter(run => run.divisionId === division).length
|
|
488
|
+
|
|
489
|
+
return queued - 1 >= sectionsLeft
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// Nobody can take it without stranding themselves, so the
|
|
493
|
+
// court stays free - it was spare to begin with.
|
|
494
|
+
if (canSpare.length === 0) {
|
|
495
|
+
return { blocked: true }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const candidates = canSpare
|
|
499
|
+
|
|
500
|
+
// Among those equally far behind, the court still goes to
|
|
501
|
+
// whichever division's band covers it, so the leftover courts
|
|
502
|
+
// at the top of a section join the division already sitting
|
|
503
|
+
// there instead of dragging one up from the bottom.
|
|
504
|
+
const behind = Math.min(...candidates.map(entry => entry.share))
|
|
505
|
+
const slack = Math.max(...candidates.map(entry => entry.step))
|
|
506
|
+
|
|
507
|
+
return candidates
|
|
508
|
+
.filter(entry => entry.share <= behind + slack)
|
|
509
|
+
.reduce((a, b) => (b.distance < a.distance ? b : a))
|
|
510
|
+
})()
|
|
511
|
+
|
|
512
|
+
if (!best) {
|
|
513
|
+
return { blocked: true }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { run: queue[best.index], index: best.index }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Taking the run is separate from choosing it: the court is picked
|
|
521
|
+
* to suit the run's division, and if no court can host it the run
|
|
522
|
+
* has to stay in the queue for a later slot.
|
|
523
|
+
*/
|
|
524
|
+
function commit(competitionId, date, sectionIndex, index) {
|
|
525
|
+
const queue = groupRunQueue.get(queueKey(competitionId, date))
|
|
526
|
+
const [run] = queue.splice(index, 1)
|
|
527
|
+
const key = `${date}:${sectionIndex}:${run.divisionId}`
|
|
528
|
+
|
|
529
|
+
sectionRunsByDivision.set(key, (sectionRunsByDivision.get(key) ?? 0) + 1)
|
|
530
|
+
groupsInSection.add(`${date}:${sectionIndex}:${run.groupId}`)
|
|
531
|
+
|
|
532
|
+
// What this group played today, so tomorrow can be offset from it.
|
|
533
|
+
const history = sectionsByGroup.get(run.groupId) ?? []
|
|
534
|
+
history.push({
|
|
535
|
+
date,
|
|
536
|
+
section: sectionIndex,
|
|
537
|
+
night: night(competitionId, date, sectionIndex),
|
|
538
|
+
})
|
|
539
|
+
sectionsByGroup.set(run.groupId, history)
|
|
540
|
+
|
|
541
|
+
return run
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Whether a section is played under lights.
|
|
546
|
+
*
|
|
547
|
+
* Asked per competition and date, never by section number alone.
|
|
548
|
+
* Competitions do not share a clock: one starting at 16:30 has its
|
|
549
|
+
* section 1 after dark while one starting at 08:00 has its at 11:00,
|
|
550
|
+
* so a bare index let one competition's evening bar every other
|
|
551
|
+
* competition's morning.
|
|
552
|
+
*/
|
|
553
|
+
function night(competitionId, date, section) {
|
|
554
|
+
return nightSections.has(`${queueKey(competitionId, date)}:${section}`)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Whether a group finished under lights on the day it last played.
|
|
559
|
+
* Recorded when the run was taken, because a section number on its
|
|
560
|
+
* own says nothing about the hour it falls in.
|
|
561
|
+
*/
|
|
562
|
+
function playedUnderLights(groupId, date) {
|
|
563
|
+
const earlier = (sectionsByGroup.get(groupId) ?? []).filter(entry => entry.date < date)
|
|
564
|
+
|
|
565
|
+
return earlier.length > 0 && earlier[earlier.length - 1].night === true
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The section a group played most recently before this date.
|
|
570
|
+
*/
|
|
571
|
+
function previousSection(groupId, date) {
|
|
572
|
+
const history = sectionsByGroup.get(groupId) ?? []
|
|
573
|
+
const earlier = history.filter(entry => entry.date < date)
|
|
574
|
+
|
|
575
|
+
return earlier.length === 0 ? null : earlier[earlier.length - 1].section
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Every competition and date that has runs queued, so the caller can
|
|
580
|
+
* work out each one's sections before play is laid out.
|
|
581
|
+
*/
|
|
582
|
+
function queued() {
|
|
583
|
+
return [...groupRunQueue.keys()].map(key => ({
|
|
584
|
+
competitionId: key.slice(0, key.indexOf(':')),
|
|
585
|
+
date: key.slice(key.indexOf(':') + 1),
|
|
586
|
+
}))
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Whether this competition/date has any runs of this planner's own
|
|
591
|
+
* shape still to place. A caller juggling more than one run-queue
|
|
592
|
+
* planner (5-team groups and 3-team groups both feed a run-queue,
|
|
593
|
+
* but from separate instances) uses this to ask which one actually
|
|
594
|
+
* owns a given competition's day, rather than guessing.
|
|
595
|
+
*/
|
|
596
|
+
function hasQueued(competitionId, date) {
|
|
597
|
+
const queue = groupRunQueue.get(queueKey(competitionId, date))
|
|
598
|
+
|
|
599
|
+
return (queue?.length ?? 0) > 0
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return { enqueue, prepare, pick, commit, previousSection, queued, hasQueued, runLengthFor }
|
|
603
|
+
}
|
package/lib/sessions.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whichever session most recently started (on or before this date and
|
|
7
|
+
* hour) determines the active phase - null if no session has started
|
|
8
|
+
* yet.
|
|
9
|
+
*/
|
|
10
|
+
export function activePhase(sessions, date, hour) {
|
|
11
|
+
let active = null
|
|
12
|
+
|
|
13
|
+
for (const session of sessions) {
|
|
14
|
+
const started = session.date === date && session.start_time <= hour
|
|
15
|
+
|
|
16
|
+
if (session.date < date || started) {
|
|
17
|
+
active = session.phase
|
|
18
|
+
} else {
|
|
19
|
+
break
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return active
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The slots a competition runs on a date.
|
|
27
|
+
*
|
|
28
|
+
* Its active session says when play starts and how long a match is, so
|
|
29
|
+
* the slots are that start time and one every playtime minutes after.
|
|
30
|
+
* There is no shared grid: doubles opens at 08:00 in 45-minute matches
|
|
31
|
+
* while Team Cup opens at 09:00 in 90-minute ones, and no single ruler
|
|
32
|
+
* measures both.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<{date: string, start_time: string, playtime: ?number,
|
|
35
|
+
* phase: string}>} sessions
|
|
36
|
+
* in date order
|
|
37
|
+
*/
|
|
38
|
+
export function activeSession(sessions, date) {
|
|
39
|
+
let active = null
|
|
40
|
+
|
|
41
|
+
for (const session of sessions) {
|
|
42
|
+
if (session.date <= date) {
|
|
43
|
+
active = session
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return active
|
|
48
|
+
}
|