@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,1297 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { bandDistance, computeSectionBands } from './court-bands.mjs'
|
|
6
|
+
import { courtUsableAt, runFits } from './court-runs.mjs'
|
|
7
|
+
import {
|
|
8
|
+
fourTeamUnitLayout, fourTeamUnitSizes, placeLayout,
|
|
9
|
+
} from './four-team-planner.mjs'
|
|
10
|
+
import { groupRuns } from './group-schedule.mjs'
|
|
11
|
+
import { wavesForDivision } from './knockout-waves.mjs'
|
|
12
|
+
import { makeFiveTeamPlanner } from './five-team-planner.mjs'
|
|
13
|
+
import { threeTeamUnitLayout, threeTeamUnits } from './three-team-planner.mjs'
|
|
14
|
+
import { makeOccupancy, courtCanHost } from './occupancy.mjs'
|
|
15
|
+
import { assignKnockout } from './knockout-planner.mjs'
|
|
16
|
+
import { KnockoutDraw } from './knockout-draw.mjs'
|
|
17
|
+
import { Plan } from './plan.mjs'
|
|
18
|
+
import { PlanningDay } from './planning-day.mjs'
|
|
19
|
+
import { activePhase, activeSession } from './sessions.mjs'
|
|
20
|
+
import { MATCH_MINUTES } from './match-time.mjs'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Automatically fills the Planning grid (which competition gets which
|
|
24
|
+
* court/hour cell) instead of a director dragging out every cell by
|
|
25
|
+
* hand. Driven entirely by each competition's configured
|
|
26
|
+
* CompetitionSession rows (its real running order - "Qualifying",
|
|
27
|
+
* "Main Draw Round 1", ... each starting at its own date+time): a
|
|
28
|
+
* competition with no sessions configured is never eligible for
|
|
29
|
+
* auto-fill at all, since there's nothing to say when it should start.
|
|
30
|
+
*
|
|
31
|
+
* A session doesn't have a hard end time - it stays "active" (and its
|
|
32
|
+
* phase keeps claiming capacity) until either the competition's next
|
|
33
|
+
* session begins or the event runs out of cells. Two sessions on the
|
|
34
|
+
* same competition are assumed never to overlap in time.
|
|
35
|
+
*
|
|
36
|
+
* Group-phase capacity is tracked per date, not as one pooled total
|
|
37
|
+
* across the whole competition (games_needed_by_date, from
|
|
38
|
+
* GameCounter::forCompetitionByDate server-side): a 5-team division's
|
|
39
|
+
* matches are split evenly across its 2 group days by the fixed
|
|
40
|
+
* template, and a pooled total would happily over-allocate whichever
|
|
41
|
+
* day comes first, starving the other entirely.
|
|
42
|
+
*
|
|
43
|
+
* Knockout capacity is tracked one round ("wave") at a time, not as
|
|
44
|
+
* one pooled total either (games_needed_by_round, from
|
|
45
|
+
* GameCounter::knockoutGamesByRoundForCompetition server-side): a
|
|
46
|
+
* bracket's first round needs the most courts and every later round
|
|
47
|
+
* needs half as many, tapering down to 1 for the Final. A pooled
|
|
48
|
+
* total has no way to express that shape - it just claims courts
|
|
49
|
+
* flatly, hour after hour, until the whole total is used up, giving a
|
|
50
|
+
* flat block instead of a taper. A competition only starts consuming
|
|
51
|
+
* its next wave once the current one is fully allocated (mirroring
|
|
52
|
+
* knockout-schedule.mjs's own round-to-round sequencing), so the
|
|
53
|
+
* courts freed up as waves shrink go to whichever other competition
|
|
54
|
+
* still needs them that hour.
|
|
55
|
+
*
|
|
56
|
+
* Court preference: pro courts are reserved for pro-designated
|
|
57
|
+
* competitions (Competition.is_pro - ITF, or "pro" in the competition
|
|
58
|
+
* or a division's name) and are never given to a non-pro competition;
|
|
59
|
+
* a pro competition can still use non-pro courts as overflow. Within
|
|
60
|
+
* one hour, courts are handed out to eligible competitions in the
|
|
61
|
+
* order they're given (the caller's own priority/sort_order), a
|
|
62
|
+
* round at a time, so no single competition can claim every court
|
|
63
|
+
* before another gets a look-in.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} params
|
|
66
|
+
* @param {Array<{id: number, lit: boolean, pro: boolean, active: boolean}>}
|
|
67
|
+
* params.courts
|
|
68
|
+
* @param {Array<{key: string, lit_required: boolean}>} params.slots
|
|
69
|
+
* Hourly columns, in order.
|
|
70
|
+
* @param {string[]} params.days Y-m-d, in order.
|
|
71
|
+
* @param {Array<Object>} params.competitions Each carries id, is_pro,
|
|
72
|
+
* games_needed {group, knockout}, games_needed_by_date,
|
|
73
|
+
* games_needed_by_round and sessions
|
|
74
|
+
* [{date, start_time, phase: 'group'|'knockout'}].
|
|
75
|
+
* Priority order for court disputes - earlier entries win.
|
|
76
|
+
* @returns {{blocks: Array<Object>,
|
|
77
|
+
* shortfall: Array<{competition_id: number, phase: string,
|
|
78
|
+
* missing: number}>,
|
|
79
|
+
* warnings: Array<{competition_id: number, kind: string, date: string,
|
|
80
|
+
* phase: string}>}}
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
const GAMES_PER_POOL_SIZE = { 3: 3, 4: 6, 5: 10 }
|
|
84
|
+
|
|
85
|
+
export function autoFillPlanning(params) {
|
|
86
|
+
const {
|
|
87
|
+
courts,
|
|
88
|
+
slots: rawSlots,
|
|
89
|
+
days,
|
|
90
|
+
competitions: allCompetitions,
|
|
91
|
+
registrationClosed = false,
|
|
92
|
+
alwaysByeShortGroups = false,
|
|
93
|
+
courtOverrides = [],
|
|
94
|
+
darkFrom = null,
|
|
95
|
+
dayEnd = '24:00',
|
|
96
|
+
groupSlotsFromSessions = false,
|
|
97
|
+
splitRounds = false,
|
|
98
|
+
} = params
|
|
99
|
+
|
|
100
|
+
const competitions = allCompetitions
|
|
101
|
+
.filter(competition => competition.available_for_planning !== false)
|
|
102
|
+
.map(competition => ({
|
|
103
|
+
...competition,
|
|
104
|
+
groups: (competition.groups ?? []).map(group => ({
|
|
105
|
+
...group,
|
|
106
|
+
team_ids: group.team_ids
|
|
107
|
+
?? Array.from({ length: group.team_count ?? 0 }, (_, i) => i),
|
|
108
|
+
})),
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
const slots = withMinutes(rawSlots ?? [])
|
|
112
|
+
const darkFromMinutes = darkFrom == null ? null : timeToMinutes(darkFrom)
|
|
113
|
+
|
|
114
|
+
const dayEndMinutes = timeToMinutes(dayEnd)
|
|
115
|
+
const plan = new Plan()
|
|
116
|
+
const blocks = plan.blocks
|
|
117
|
+
const remainingGroupByDate = new Map()
|
|
118
|
+
const fiveTeam = makeFiveTeamPlanner()
|
|
119
|
+
const fourTeamGroups = []
|
|
120
|
+
const threeTeamGroups = []
|
|
121
|
+
const boltOnGroups = []
|
|
122
|
+
const noTemplateWarned = new Set()
|
|
123
|
+
let fourTeamUnits = null
|
|
124
|
+
let builtThreeTeamUnits = null
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether this competition is mostly played on shared pairs.
|
|
129
|
+
*
|
|
130
|
+
* A competition of one shape is laid out by that shape's planner and
|
|
131
|
+
* there is nothing to decide. A mixed one has to choose which goes
|
|
132
|
+
* first, because the two do not divide into each other: a pair runs
|
|
133
|
+
* in threes and a 5-team group in fives, so whichever is laid down
|
|
134
|
+
* second has to fit around the other's leftovers.
|
|
135
|
+
*
|
|
136
|
+
* The shape with the most matches takes the courts first. Amateur
|
|
137
|
+
* Doubles is mostly fives, so its handful of fours fill in around
|
|
138
|
+
* them. Singles is the other way round, and planning its one 5-team
|
|
139
|
+
* group first left a court with four slots nothing could use, since
|
|
140
|
+
* nothing in the tournament is four slots long.
|
|
141
|
+
*/
|
|
142
|
+
function pooledLeads(competition) {
|
|
143
|
+
let pooled = 0
|
|
144
|
+
let owned = 0
|
|
145
|
+
|
|
146
|
+
for (const group of competition.groups ?? []) {
|
|
147
|
+
const teams = (group.team_ids ?? []).length
|
|
148
|
+
const matches = (teams * (teams - 1)) / 2
|
|
149
|
+
|
|
150
|
+
if (shortGroup(group)) {
|
|
151
|
+
pooled += matches
|
|
152
|
+
} else {
|
|
153
|
+
owned += matches
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return pooled > owned
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A group with fewer teams than its division is configured for. It
|
|
162
|
+
* plays three matches a day on a court pair shared with other short
|
|
163
|
+
* groups, so it is held back from the ordinary allocation.
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* Whether a group is played on a shared pair of courts rather than
|
|
167
|
+
* one of its own.
|
|
168
|
+
*
|
|
169
|
+
* Every 4-team group is: three rounds of two matches, a round taking
|
|
170
|
+
* both courts, two or three groups taking turns. That is how a
|
|
171
|
+
* 4-team group is played, not a special case for an uneven one.
|
|
172
|
+
*
|
|
173
|
+
* The exception is a group of four sitting in a division configured
|
|
174
|
+
* for five. While registration is open it may still fill from the
|
|
175
|
+
* waitlist, so capacity is held for it as a five - the spare cells
|
|
176
|
+
* are the point, not waste. Once registration closes it is a 4-team
|
|
177
|
+
* group like any other.
|
|
178
|
+
*/
|
|
179
|
+
function shortGroup(group) {
|
|
180
|
+
const teams = (group.team_ids ?? []).length
|
|
181
|
+
|
|
182
|
+
if (teams === 3) {
|
|
183
|
+
return group.pool_size >= 4
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (teams !== 4) {
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return group.pool_size === 4
|
|
191
|
+
|| (registrationClosed && !alwaysByeShortGroups)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/*
|
|
195
|
+
* The dates a competition is actually in its group phase.
|
|
196
|
+
*
|
|
197
|
+
* group_dates says which days the draw wants; the sessions say what
|
|
198
|
+
* the competition is doing on them, and the two disagree whenever
|
|
199
|
+
* the last group date is also the day it switches to knockout.
|
|
200
|
+
*
|
|
201
|
+
* A unit cuts its sequence into one part per date. Counting a
|
|
202
|
+
* knockout day among them handed a half to a day that then refused
|
|
203
|
+
* it, and the matches were simply lost: three of the four pooled
|
|
204
|
+
* competitions placed exactly half of theirs.
|
|
205
|
+
*/
|
|
206
|
+
function groupDatesFor(competition) {
|
|
207
|
+
const sessions = sortedSessions.get(competition.id) ?? []
|
|
208
|
+
|
|
209
|
+
return (competition.group_dates ?? []).filter(date => {
|
|
210
|
+
const own = slotsForDate(sessions, date, darkFromMinutes, dayEndMinutes)
|
|
211
|
+
|
|
212
|
+
return own.length > 0 && own[0].phase === 'group'
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildFourTeamUnits() {
|
|
217
|
+
const pools = new Map()
|
|
218
|
+
|
|
219
|
+
for (const entry of fourTeamGroups) {
|
|
220
|
+
const dates = groupDatesFor(entry.competition)
|
|
221
|
+
const pairing = entry.group.pairing ?? 'auto'
|
|
222
|
+
|
|
223
|
+
const key = [
|
|
224
|
+
entry.group.is_pro === true ? 'pro' : 'open', pairing, ...dates,
|
|
225
|
+
].join('|')
|
|
226
|
+
|
|
227
|
+
if (!pools.has(key)) {
|
|
228
|
+
pools.set(key, {
|
|
229
|
+
dates, isPro: entry.group.is_pro === true, pairing, entries: [],
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pools.get(key).entries.push(entry)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const units = []
|
|
237
|
+
|
|
238
|
+
for (const { dates, isPro, pairing, entries } of pools.values()) {
|
|
239
|
+
const dense = pairing === 'triples'
|
|
240
|
+
? true
|
|
241
|
+
: pairing === 'pairs' ? false : undefined
|
|
242
|
+
const short = entries
|
|
243
|
+
.filter(entry => (entry.group.team_ids ?? []).length < 4)
|
|
244
|
+
const full = entries
|
|
245
|
+
.filter(entry => (entry.group.team_ids ?? []).length >= 4)
|
|
246
|
+
|
|
247
|
+
const pooled = short.length >= 2
|
|
248
|
+
? [{ list: full, tail: false }, { list: short, tail: true }]
|
|
249
|
+
: [{ list: entries, tail: false }]
|
|
250
|
+
|
|
251
|
+
for (const { list } of pooled) {
|
|
252
|
+
let cursor = 0
|
|
253
|
+
|
|
254
|
+
for (const size of fourTeamUnitSizes(list.length, { dense })) {
|
|
255
|
+
units.push({
|
|
256
|
+
dates,
|
|
257
|
+
isPro,
|
|
258
|
+
divisionId: list[cursor]?.group.division_id ?? null,
|
|
259
|
+
entries: list.slice(cursor, cursor + size),
|
|
260
|
+
placed: new Set(),
|
|
261
|
+
})
|
|
262
|
+
cursor += size
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return units
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The same bundling as buildFourTeamUnits, for genuine 3-team
|
|
272
|
+
* groups. No "short"/dense split here - a group reaching this pass
|
|
273
|
+
* (see the group loop above) is already exactly 3 real teams, never
|
|
274
|
+
* a vacant-seat bye, and there is no pairs/triples choice to honour
|
|
275
|
+
* (see paired-court-planner.mjs's TODO).
|
|
276
|
+
*/
|
|
277
|
+
function buildThreeTeamUnits() {
|
|
278
|
+
const pools = new Map()
|
|
279
|
+
|
|
280
|
+
for (const entry of threeTeamGroups) {
|
|
281
|
+
const dates = groupDatesFor(entry.competition)
|
|
282
|
+
|
|
283
|
+
const isTeamCup = (entry.group.team_size ?? 2) >= 4
|
|
284
|
+
|
|
285
|
+
const key = isTeamCup
|
|
286
|
+
? `solo:${entry.group.id}`
|
|
287
|
+
: [entry.group.is_pro === true ? 'pro' : 'open', ...dates].join('|')
|
|
288
|
+
|
|
289
|
+
if (!pools.has(key)) {
|
|
290
|
+
pools.set(key, {
|
|
291
|
+
dates, isPro: entry.group.is_pro === true, entries: [],
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
pools.get(key).entries.push(entry)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const units = []
|
|
299
|
+
|
|
300
|
+
for (const { dates, isPro, entries } of pools.values()) {
|
|
301
|
+
const built = entries.length === 1 ? [entries] : threeTeamUnits(entries)
|
|
302
|
+
|
|
303
|
+
for (const unitEntries of built) {
|
|
304
|
+
units.push({
|
|
305
|
+
dates,
|
|
306
|
+
isPro,
|
|
307
|
+
divisionId: unitEntries[0]?.group.division_id ?? null,
|
|
308
|
+
entries: unitEntries,
|
|
309
|
+
placed: new Set(),
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return units
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const knockoutUnits = []
|
|
318
|
+
|
|
319
|
+
const proDivisions = new Set()
|
|
320
|
+
|
|
321
|
+
for (const competition of competitions) {
|
|
322
|
+
for (const group of competition.groups ?? []) {
|
|
323
|
+
if (group.is_pro === true && group.division_id != null) {
|
|
324
|
+
proDivisions.add(group.division_id)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sortedSessions = new Map()
|
|
330
|
+
for (const competition of competitions) {
|
|
331
|
+
sortedSessions.set(
|
|
332
|
+
competition.id,
|
|
333
|
+
[...competition.sessions].sort((a, b) =>
|
|
334
|
+
(a.date + a.start_time).localeCompare(b.date + b.start_time)
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const competition of competitions) {
|
|
340
|
+
const derived = (competition.divisions ?? [])
|
|
341
|
+
.filter(division =>
|
|
342
|
+
division.knockout_matches != null || division.team_count != null
|
|
343
|
+
)
|
|
344
|
+
.map(division => ({
|
|
345
|
+
division_id: division.id,
|
|
346
|
+
division_name: division.name,
|
|
347
|
+
division_gender: division.gender ?? null,
|
|
348
|
+
level: division.level ?? 0,
|
|
349
|
+
waves: wavesForDivision(division, { itf: competition.itf === true }),
|
|
350
|
+
}))
|
|
351
|
+
.filter(bracket => bracket.waves.length > 0)
|
|
352
|
+
|
|
353
|
+
const brackets = derived.length > 0
|
|
354
|
+
? derived
|
|
355
|
+
: (competition.knockout_rounds_by_division
|
|
356
|
+
?? (competition.games_needed_by_round?.length
|
|
357
|
+
? [competition.games_needed_by_round]
|
|
358
|
+
: []))
|
|
359
|
+
|
|
360
|
+
for (const bracket of brackets) {
|
|
361
|
+
const waves = Array.isArray(bracket) ? bracket : bracket.waves
|
|
362
|
+
const division = Array.isArray(bracket) ? null : bracket
|
|
363
|
+
|
|
364
|
+
knockoutUnits.push(new KnockoutDraw({
|
|
365
|
+
competition,
|
|
366
|
+
waves,
|
|
367
|
+
divisionId: division?.division_id ?? null,
|
|
368
|
+
divisionName: division?.division_name ?? null,
|
|
369
|
+
divisionGender: division?.division_gender ?? null,
|
|
370
|
+
level: division?.level ?? 0,
|
|
371
|
+
isPro: proDivisions.has(division?.division_id),
|
|
372
|
+
}))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (Array.isArray(competition.groups) && competition.groups.length === 0) {
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const boltOn = pooledLeads(competition)
|
|
380
|
+
|
|
381
|
+
for (const group of competition.groups ?? []) {
|
|
382
|
+
if (shortGroup(group)) {
|
|
383
|
+
const size = group.pool_size ?? group.team_ids.length
|
|
384
|
+
const asFull = alwaysByeShortGroups || !registrationClosed
|
|
385
|
+
const padded = asFull && size > group.team_ids.length
|
|
386
|
+
? paddedWithByes(group, size)
|
|
387
|
+
: group
|
|
388
|
+
|
|
389
|
+
fourTeamGroups.push({ competition, group: padded })
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if ((group.team_ids ?? []).length === 3) {
|
|
394
|
+
threeTeamGroups.push({ competition, group, placed: new Set() })
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (boltOn) {
|
|
399
|
+
boltOnGroups.push({ competition, group, placed: new Set() })
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const { date, count } of fiveTeam.enqueue({
|
|
404
|
+
competition,
|
|
405
|
+
group,
|
|
406
|
+
dates: groupDatesFor(competition),
|
|
407
|
+
asFull: alwaysByeShortGroups || !registrationClosed,
|
|
408
|
+
})) {
|
|
409
|
+
const key = groupKey(competition.id, date)
|
|
410
|
+
remainingGroupByDate.set(
|
|
411
|
+
key, (remainingGroupByDate.get(key) ?? 0) + count
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function remainingFor(competitionId, phase, date) {
|
|
418
|
+
return remainingGroupByDate.get(groupKey(competitionId, date)) ?? 0
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function decrement(competitionId, phase, date) {
|
|
422
|
+
const groupDateKey = groupKey(competitionId, date)
|
|
423
|
+
remainingGroupByDate.set(
|
|
424
|
+
groupDateKey, remainingGroupByDate.get(groupDateKey) - 1
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const activeCourts = courts.filter(court => court.active)
|
|
429
|
+
const proCourts = activeCourts.filter(court => court.pro)
|
|
430
|
+
const openCourts = activeCourts.filter(court => !court.pro)
|
|
431
|
+
|
|
432
|
+
const overridesByDate = new Map()
|
|
433
|
+
|
|
434
|
+
for (const override of courtOverrides) {
|
|
435
|
+
if (!overridesByDate.has(override.date)) {
|
|
436
|
+
overridesByDate.set(override.date, new Map())
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
overridesByDate.get(override.date).set(
|
|
440
|
+
override.court_id,
|
|
441
|
+
override.available_from ? timeToMinutes(override.available_from) : null
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The non-pro courts usable on this date - openCourts, plus any
|
|
447
|
+
* normally-pro court a director has opened up for the day. The
|
|
448
|
+
* override travels with the court object itself (availableFromMinutes),
|
|
449
|
+
* so courtUsableAt can enforce it without needing to know the date.
|
|
450
|
+
*/
|
|
451
|
+
function openCourtsFor(date) {
|
|
452
|
+
const overrides = overridesByDate.get(date)
|
|
453
|
+
|
|
454
|
+
if (!overrides) {
|
|
455
|
+
return openCourts
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const opened = new Map(proCourts
|
|
459
|
+
.filter(court => overrides.has(court.id))
|
|
460
|
+
.map(court => [court.id, {
|
|
461
|
+
...court, availableFromMinutes: overrides.get(court.id),
|
|
462
|
+
}]))
|
|
463
|
+
|
|
464
|
+
if (opened.size === 0) {
|
|
465
|
+
return openCourts
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return activeCourts
|
|
469
|
+
.filter(court => !court.pro || opened.has(court.id))
|
|
470
|
+
.map(court => opened.get(court.id) ?? court)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The hour the day's first competition opens, which every
|
|
475
|
+
* competition's slots are measured from.
|
|
476
|
+
*/
|
|
477
|
+
function anchorFor(date) {
|
|
478
|
+
const opens = []
|
|
479
|
+
|
|
480
|
+
for (const competition of competitions) {
|
|
481
|
+
const session = activeSession(
|
|
482
|
+
sortedSessions.get(competition.id) ?? [], date
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if (!session) {
|
|
486
|
+
continue
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (session.phase === 'knockout') {
|
|
490
|
+
const stillPlaying = knockoutUnits.some(draw =>
|
|
491
|
+
draw.competition.id === competition.id && !draw.done
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if (!stillPlaying) {
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
opens.push(timeToMinutes(session.start_time))
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return opens.length === 0 ? null : Math.min(...opens)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function slotsFor(competitionId, date) {
|
|
506
|
+
if (!groupSlotsFromSessions && slots.length > 0) {
|
|
507
|
+
return slots
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return slotsForDate(
|
|
511
|
+
sortedSessions.get(Number(competitionId)) ?? [],
|
|
512
|
+
date, darkFromMinutes, dayEndMinutes, anchorFor(date)
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
fiveTeam.prepare({ slotsFor, courts: openCourts, usable: courtUsableAt })
|
|
517
|
+
|
|
518
|
+
for (const date of days) {
|
|
519
|
+
const taken = makeOccupancy()
|
|
520
|
+
|
|
521
|
+
const slotsByCompetition = new Map(
|
|
522
|
+
competitions.map(competition => [
|
|
523
|
+
competition.id,
|
|
524
|
+
competition.slots
|
|
525
|
+
? withMinutes(competition.slots)
|
|
526
|
+
: ((groupSlotsFromSessions || slots.length === 0)
|
|
527
|
+
? slotsForDate(
|
|
528
|
+
sortedSessions.get(competition.id) ?? [],
|
|
529
|
+
date, darkFromMinutes, dayEndMinutes, anchorFor(date)
|
|
530
|
+
)
|
|
531
|
+
: slots),
|
|
532
|
+
])
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
function assign(entries, requireLit) {
|
|
536
|
+
const eligible = entries.filter(({ competition, phase }) =>
|
|
537
|
+
remainingFor(competition.id, phase, date) > 0
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if (eligible.length === 0) {
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const context = {
|
|
545
|
+
date, taken, blocks, remainingFor, decrement, fiveTeam, requireLit,
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
assignCourts(
|
|
549
|
+
proCourts,
|
|
550
|
+
eligible.filter(({ competition }) => competition.is_pro),
|
|
551
|
+
{ ...context, proCourt: true }
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
assignCourts(openCourtsFor(date), eligible, context)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const timeline = []
|
|
558
|
+
|
|
559
|
+
for (const competition of competitions) {
|
|
560
|
+
const own = slotsByCompetition.get(competition.id) ?? []
|
|
561
|
+
|
|
562
|
+
const runLength = fiveTeam.runLengthFor(competition.id, date)
|
|
563
|
+
|
|
564
|
+
own.forEach((slot, slotIndex) => {
|
|
565
|
+
const phase = slot.phase
|
|
566
|
+
?? activePhase(sortedSessions.get(competition.id), date, slot.key)
|
|
567
|
+
|
|
568
|
+
if (phase !== 'group') {
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const last = own[Math.min(slotIndex + runLength - 1, own.length - 1)]
|
|
573
|
+
const needsLight = runNeedsLight(
|
|
574
|
+
own, slotIndex, runLength, darkFromMinutes
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
timeline.push({
|
|
578
|
+
competition, phase: 'group', slots: own, slotIndex, slot, needsLight,
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const lit = byStartTime(timeline.filter(e => e.needsLight), true)
|
|
584
|
+
|
|
585
|
+
for (const [, entries] of lit) {
|
|
586
|
+
assign(entries, true)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const daylight = byStartTime(timeline.filter(e => !e.needsLight), false)
|
|
590
|
+
|
|
591
|
+
for (const [, entries] of daylight) {
|
|
592
|
+
assign(entries, false)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function placeGroupUnit(unit, unitLayoutFn) {
|
|
596
|
+
const unitDates = unit.dates.slice(0, 2)
|
|
597
|
+
const dayIndex = unitDates.indexOf(date)
|
|
598
|
+
|
|
599
|
+
if (dayIndex === -1 || unit.placed.has(date)) {
|
|
600
|
+
return false
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const unitSlots = slotsByCompetition
|
|
604
|
+
.get(unit.entries[0].competition.id) ?? slots
|
|
605
|
+
|
|
606
|
+
if (unitSlots.length === 0 || unitSlots[0].phase === 'knockout') {
|
|
607
|
+
return false
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const sequence = unitLayoutFn(unit.entries.map(entry => ({
|
|
611
|
+
id: entry.group.id,
|
|
612
|
+
teamIds: entry.group.team_ids,
|
|
613
|
+
groupSize: entry.group.pool_size,
|
|
614
|
+
})))
|
|
615
|
+
|
|
616
|
+
const firstDay = Math.floor(sequence.length / unitDates.length)
|
|
617
|
+
const layout = dayIndex === 0
|
|
618
|
+
? sequence.slice(0, firstDay)
|
|
619
|
+
: sequence.slice(firstDay)
|
|
620
|
+
|
|
621
|
+
const before = blocks.length
|
|
622
|
+
|
|
623
|
+
const placed = placeLayout({
|
|
624
|
+
layout,
|
|
625
|
+
lookup: groupId => {
|
|
626
|
+
const entry = unit.entries.find(x => x.group.id === groupId)
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
id: entry.group.id,
|
|
630
|
+
competitionId: entry.competition.id,
|
|
631
|
+
divisionId: entry.group.division_id ?? null,
|
|
632
|
+
divisionName: entry.group.division_name ?? null,
|
|
633
|
+
divisionGender: entry.group.division_gender ?? null,
|
|
634
|
+
groupName: entry.group.name ?? null,
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
courts: unit.isPro ? proCourts : openCourtsFor(date),
|
|
638
|
+
slots: unitSlots,
|
|
639
|
+
date,
|
|
640
|
+
occupancy: taken,
|
|
641
|
+
darkFrom: darkFromMinutes,
|
|
642
|
+
blocks,
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
if (placed) {
|
|
646
|
+
unit.placed.add(date)
|
|
647
|
+
return new Set(blocks.slice(before).map(block => block.court_id)).size
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return false
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function placeGroupUnitsForDate(units, unitLayoutFn) {
|
|
654
|
+
for (const kind of [true, false]) {
|
|
655
|
+
const due = units.filter(unit =>
|
|
656
|
+
unit.isPro === kind && !unit.placed.has(date)
|
|
657
|
+
&& unit.dates.includes(date)
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
if (due.length === 0) {
|
|
661
|
+
continue
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const order = []
|
|
665
|
+
|
|
666
|
+
for (const unit of due) {
|
|
667
|
+
if (!order.includes(unit.divisionId)) {
|
|
668
|
+
order.push(unit.divisionId)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const roomCourts = (kind ? proCourts : openCourtsFor(date)).length
|
|
673
|
+
|
|
674
|
+
const quota = new Map(order.map((divisionId, index) => {
|
|
675
|
+
const mine = due.filter(unit => unit.divisionId === divisionId)
|
|
676
|
+
const share = mine.length / due.length
|
|
677
|
+
|
|
678
|
+
return [
|
|
679
|
+
divisionId,
|
|
680
|
+
index === order.length - 1
|
|
681
|
+
? Infinity
|
|
682
|
+
: Math.max(2, Math.round(share * roomCourts)),
|
|
683
|
+
]
|
|
684
|
+
}))
|
|
685
|
+
|
|
686
|
+
const claimed = new Map()
|
|
687
|
+
|
|
688
|
+
for (const unit of due) {
|
|
689
|
+
const mine = claimed.get(unit.divisionId) ?? 0
|
|
690
|
+
|
|
691
|
+
if (mine >= (quota.get(unit.divisionId) ?? Infinity)) {
|
|
692
|
+
continue
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const courtsUsed = placeGroupUnit(unit, unitLayoutFn)
|
|
696
|
+
|
|
697
|
+
if (courtsUsed) {
|
|
698
|
+
claimed.set(
|
|
699
|
+
unit.divisionId, (claimed.get(unit.divisionId) ?? 0) + courtsUsed
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
for (const unit of due) {
|
|
705
|
+
if (!unit.placed.has(date)) {
|
|
706
|
+
placeGroupUnit(unit, unitLayoutFn)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
fourTeamUnits ??= buildFourTeamUnits()
|
|
713
|
+
placeGroupUnitsForDate(fourTeamUnits, fourTeamUnitLayout)
|
|
714
|
+
|
|
715
|
+
builtThreeTeamUnits ??= buildThreeTeamUnits()
|
|
716
|
+
placeGroupUnitsForDate(builtThreeTeamUnits, threeTeamUnitLayout)
|
|
717
|
+
|
|
718
|
+
for (const entry of boltOnGroups) {
|
|
719
|
+
const opened = groupDatesFor(entry.competition).includes(date)
|
|
720
|
+
|
|
721
|
+
if (entry.placed.has(date) || !opened) {
|
|
722
|
+
continue
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const own = slotsByCompetition.get(entry.competition.id) ?? []
|
|
726
|
+
|
|
727
|
+
if (own.length === 0 || own[0].phase === 'knockout') {
|
|
728
|
+
continue
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const dates = groupDatesFor(entry.competition)
|
|
732
|
+
const teamIds = entry.group.team_ids ?? []
|
|
733
|
+
const size = entry.group.pool_size ?? teamIds.length
|
|
734
|
+
|
|
735
|
+
const asFull = alwaysByeShortGroups || !registrationClosed
|
|
736
|
+
const seats = asFull && size > teamIds.length
|
|
737
|
+
? Array.from({ length: size }, (_, i) => teamIds[i] ?? -(i + 1))
|
|
738
|
+
: teamIds
|
|
739
|
+
|
|
740
|
+
const runs = groupRuns({ teamIds: seats, groupSize: size })
|
|
741
|
+
|
|
742
|
+
if (runs.length === 0 && teamIds.length > 0
|
|
743
|
+
&& !noTemplateWarned.has(entry.group.id)) {
|
|
744
|
+
noTemplateWarned.add(entry.group.id)
|
|
745
|
+
|
|
746
|
+
plan.warn({
|
|
747
|
+
competition_id: entry.competition.id,
|
|
748
|
+
kind: 'no-template',
|
|
749
|
+
division_id: entry.group.division_id ?? null,
|
|
750
|
+
division_name: entry.group.division_name ?? null,
|
|
751
|
+
group_id: entry.group.id,
|
|
752
|
+
group_name: entry.group.name ?? null,
|
|
753
|
+
teams: teamIds.length,
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
plan.short(
|
|
757
|
+
entry.competition, 'group',
|
|
758
|
+
GAMES_PER_POOL_SIZE[entry.group.pool_size] ?? 6
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const today = runs.filter(run =>
|
|
763
|
+
(dates.length === 1 ? dates[0] : dates[run.day - 1]) === date
|
|
764
|
+
)
|
|
765
|
+
const cells = today.flatMap(run => run.positions)
|
|
766
|
+
|
|
767
|
+
if (cells.length === 0) {
|
|
768
|
+
continue
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const laneCourts = entry.group.is_pro === true
|
|
772
|
+
? proCourts
|
|
773
|
+
: openCourtsFor(date)
|
|
774
|
+
let landed = false
|
|
775
|
+
|
|
776
|
+
const last = own.length - cells.length
|
|
777
|
+
|
|
778
|
+
for (let start = 0; start <= last && !landed; start++) {
|
|
779
|
+
const window = own.slice(start, start + cells.length)
|
|
780
|
+
|
|
781
|
+
for (const court of laneCourts) {
|
|
782
|
+
const usable = window.every(slot =>
|
|
783
|
+
courtUsableAt(court, slot)
|
|
784
|
+
&& taken.free(
|
|
785
|
+
date, court.id, slot.start_minutes, slot.end_minutes
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
if (!usable) {
|
|
790
|
+
continue
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
window.forEach((slot, offset) => {
|
|
794
|
+
blocks.push({
|
|
795
|
+
competition_id: entry.competition.id,
|
|
796
|
+
division_id: entry.group.division_id ?? null,
|
|
797
|
+
division_name: entry.group.division_name ?? null,
|
|
798
|
+
division_gender: entry.group.division_gender ?? null,
|
|
799
|
+
group_id: entry.group.id,
|
|
800
|
+
group_name: entry.group.name ?? null,
|
|
801
|
+
phase: 'group',
|
|
802
|
+
round: 'group',
|
|
803
|
+
match_slot: cells[offset] ?? null,
|
|
804
|
+
court_id: court.id,
|
|
805
|
+
date,
|
|
806
|
+
slot: slot.key,
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
taken.take(date, court.id, slot.start_minutes, slot.end_minutes)
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
entry.placed.add(date)
|
|
813
|
+
landed = true
|
|
814
|
+
break
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (!landed) {
|
|
819
|
+
plan.short(entry.competition, 'group', cells.length)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const day = new PlanningDay({
|
|
824
|
+
date,
|
|
825
|
+
proCourts,
|
|
826
|
+
openCourts: openCourtsFor(date),
|
|
827
|
+
slotsByCompetition,
|
|
828
|
+
occupancy: taken,
|
|
829
|
+
plan,
|
|
830
|
+
darkFromMinutes,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
assignKnockout(day, {
|
|
834
|
+
competitions,
|
|
835
|
+
draws: knockoutUnits,
|
|
836
|
+
sessions: sortedSessions,
|
|
837
|
+
split: splitRounds,
|
|
838
|
+
})
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
refuseLateStarts({
|
|
842
|
+
competitions, sortedSessions, plan, overridesByDate,
|
|
843
|
+
slotsFor, runLengthFor: (id, date) => fiveTeam.runLengthFor(id, date),
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
const placed = plan.blocks.filter(block => !plan.dropped.has(block))
|
|
847
|
+
|
|
848
|
+
for (const entry of threeTeamGroups) {
|
|
849
|
+
const got = placed.filter(block =>
|
|
850
|
+
block.group_id === entry.group.id && block.phase === 'group'
|
|
851
|
+
).length
|
|
852
|
+
const missing = GAMES_PER_POOL_SIZE[3] - got
|
|
853
|
+
|
|
854
|
+
if (missing > 0) {
|
|
855
|
+
plan.short(entry.competition, 'group', missing)
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
for (const competition of competitions) {
|
|
860
|
+
if (competition.sessions.length === 0) {
|
|
861
|
+
continue
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const missingGroup = [...remainingGroupByDate]
|
|
865
|
+
.filter(([key]) =>
|
|
866
|
+
key.slice(0, key.indexOf(':')) === String(competition.id)
|
|
867
|
+
)
|
|
868
|
+
.reduce((total, [, left]) => total + Math.max(left, 0), 0)
|
|
869
|
+
|
|
870
|
+
const missingKnockout = knockoutUnits
|
|
871
|
+
.filter(draw => draw.competition.id === competition.id)
|
|
872
|
+
.reduce((total, draw) => total + draw.unplanned, 0)
|
|
873
|
+
|
|
874
|
+
plan.short(competition, 'group', missingGroup)
|
|
875
|
+
plan.short(competition, 'knockout', missingKnockout)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const result = plan.result(competitions)
|
|
879
|
+
|
|
880
|
+
const overflow = knockoutOverflow(competitions, result)
|
|
881
|
+
|
|
882
|
+
if (splitRounds || overflow === 0) {
|
|
883
|
+
return result
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const retried = autoFillPlanning({ ...params, splitRounds: true })
|
|
887
|
+
|
|
888
|
+
return knockoutOverflow(competitions, retried) < overflow ? retried : result
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* How much of the event's knockout did not fit where it was meant to:
|
|
893
|
+
* capacity nobody could place at all, plus every cell that landed on
|
|
894
|
+
* a day its competition's knockout was never scheduled for.
|
|
895
|
+
*
|
|
896
|
+
* A competition with no knockout session of its own is not counted -
|
|
897
|
+
* it was never given a day to overflow from.
|
|
898
|
+
*/
|
|
899
|
+
/**
|
|
900
|
+
* When a session opens, allowing for the rhythm runs are laid out on.
|
|
901
|
+
*
|
|
902
|
+
* Runs start on a section boundary, so a session opening part way
|
|
903
|
+
* through one is due on at the next: that is the rhythm, not lateness.
|
|
904
|
+
*
|
|
905
|
+
* @param {Array} own The session's own slots, in order.
|
|
906
|
+
* @param {string} startTime The session's configured start.
|
|
907
|
+
* @param {number} runLength Slots to a run in this phase.
|
|
908
|
+
* @returns {?Object} The slot it is due on, or null where the day has
|
|
909
|
+
* no such slot at all.
|
|
910
|
+
*/
|
|
911
|
+
/**
|
|
912
|
+
* Whether a group run has to have a lit court.
|
|
913
|
+
*
|
|
914
|
+
* A question about the whole run, not the slot it starts in: everyone
|
|
915
|
+
* on court at the same time plays in the same conditions, so a section
|
|
916
|
+
* running past dark needs light from its first match. Where no
|
|
917
|
+
* darkness time is given, the slots' own flags are all there is to go
|
|
918
|
+
* on.
|
|
919
|
+
*
|
|
920
|
+
* @param {Array} own The competition's slots for the day.
|
|
921
|
+
* @param {number} slotIndex Where the run starts.
|
|
922
|
+
* @param {number} runLength Slots to the run.
|
|
923
|
+
* @param {?number} darkFromMinutes When the light goes, if known.
|
|
924
|
+
* @returns {boolean}
|
|
925
|
+
*/
|
|
926
|
+
function runNeedsLight(own, slotIndex, runLength, darkFromMinutes) {
|
|
927
|
+
if (darkFromMinutes == null) {
|
|
928
|
+
return own.slice(slotIndex, slotIndex + runLength)
|
|
929
|
+
.some(slot => slot.lit_required)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const last = own[Math.min(slotIndex + runLength - 1, own.length - 1)]
|
|
933
|
+
|
|
934
|
+
return last.end_minutes > darkFromMinutes
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Timeline entries grouped by the moment they start.
|
|
939
|
+
*
|
|
940
|
+
* Runs needing light are worked latest first, because only a lit court
|
|
941
|
+
* can serve them and the evening runs out: the scarce capacity is
|
|
942
|
+
* spent before the day's abundant daytime capacity is. Everything else
|
|
943
|
+
* goes earliest first.
|
|
944
|
+
*
|
|
945
|
+
* @param {Array} entries
|
|
946
|
+
* @param {boolean} requireLit
|
|
947
|
+
* @returns {Array<[number, Array]>} Start minute and its entries.
|
|
948
|
+
*/
|
|
949
|
+
function byStartTime(entries, requireLit) {
|
|
950
|
+
const byStart = new Map()
|
|
951
|
+
|
|
952
|
+
for (const entry of entries) {
|
|
953
|
+
const key = entry.slot.start_minutes
|
|
954
|
+
|
|
955
|
+
if (!byStart.has(key)) {
|
|
956
|
+
byStart.set(key, [])
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
byStart.get(key).push(entry)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return [...byStart.entries()]
|
|
963
|
+
.sort((a, b) => (requireLit ? b[0] - a[0] : a[0] - b[0]))
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* A short group filled out to its full size with byes.
|
|
968
|
+
*
|
|
969
|
+
* The empty seats travel as negative ids: a template needs a team in
|
|
970
|
+
* every position to lay out at all, and a negative id can never
|
|
971
|
+
* collide with a real one.
|
|
972
|
+
*
|
|
973
|
+
* @param {Object} group
|
|
974
|
+
* @param {number} size The group's full pool size.
|
|
975
|
+
* @returns {Object} A copy, padded.
|
|
976
|
+
*/
|
|
977
|
+
function paddedWithByes(group, size) {
|
|
978
|
+
return {
|
|
979
|
+
...group,
|
|
980
|
+
team_ids: Array.from(
|
|
981
|
+
{ length: size }, (_, i) => group.team_ids[i] ?? -(i + 1)
|
|
982
|
+
),
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function dueSlot(own, startTime, runLength) {
|
|
987
|
+
const from = own.findIndex(slot => slot.key >= startTime)
|
|
988
|
+
|
|
989
|
+
if (from === -1) {
|
|
990
|
+
return null
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return own[Math.ceil(from / runLength) * runLength] ?? null
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Drops the plan for any competition that could not open when it was
|
|
998
|
+
* told to, and says so.
|
|
999
|
+
*
|
|
1000
|
+
* A competition starts when its session says. If not one of its
|
|
1001
|
+
* divisions can take a court in that hour, the configuration and the
|
|
1002
|
+
* courts disagree, and that is a planning error rather than something
|
|
1003
|
+
* to work around quietly - sliding it later and saying nothing is how
|
|
1004
|
+
* a start time nobody chose survived a week of planning. The cells it
|
|
1005
|
+
* would have taken are left for whoever can use them.
|
|
1006
|
+
*
|
|
1007
|
+
* A court override on the date is a director's own deliberate,
|
|
1008
|
+
* informed reason to start later, so it is exempt.
|
|
1009
|
+
*
|
|
1010
|
+
* @param {Object} params
|
|
1011
|
+
* @param {Plan} params.plan Refusals and warnings are recorded on it.
|
|
1012
|
+
*/
|
|
1013
|
+
function refuseLateStarts({
|
|
1014
|
+
competitions, sortedSessions, plan, overridesByDate,
|
|
1015
|
+
slotsFor, runLengthFor,
|
|
1016
|
+
}) {
|
|
1017
|
+
for (const competition of competitions) {
|
|
1018
|
+
for (const session of sortedSessions.get(competition.id) ?? []) {
|
|
1019
|
+
const mine = plan.cellsFor(competition, session.date, session.phase)
|
|
1020
|
+
|
|
1021
|
+
if (mine.length === 0 || overridesByDate.has(session.date)) {
|
|
1022
|
+
continue
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const own = slotsFor(competition.id, session.date)
|
|
1026
|
+
.filter(slot => (slot.phase ?? session.phase) === session.phase)
|
|
1027
|
+
|
|
1028
|
+
const runLength = session.phase === 'group'
|
|
1029
|
+
? runLengthFor(competition.id, session.date)
|
|
1030
|
+
: 1
|
|
1031
|
+
|
|
1032
|
+
const due = dueSlot(own, session.start_time, runLength)
|
|
1033
|
+
|
|
1034
|
+
if (!due) {
|
|
1035
|
+
continue
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const opens = due.start_minutes ?? timeToMinutes(due.key)
|
|
1039
|
+
const earliest = Math.min(...mine.map(block => timeToMinutes(block.slot)))
|
|
1040
|
+
|
|
1041
|
+
if (earliest <= opens) {
|
|
1042
|
+
continue
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
plan.refuse(competition, session.phase, mine)
|
|
1046
|
+
|
|
1047
|
+
plan.warn({
|
|
1048
|
+
competition_id: competition.id,
|
|
1049
|
+
kind: 'late-start',
|
|
1050
|
+
date: session.date,
|
|
1051
|
+
phase: session.phase,
|
|
1052
|
+
expected: minutesToTime(opens),
|
|
1053
|
+
earliest: minutesToTime(earliest),
|
|
1054
|
+
late: earliest - opens,
|
|
1055
|
+
refused: true,
|
|
1056
|
+
})
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function knockoutOverflow(competitions, { blocks, shortfall }) {
|
|
1062
|
+
const scheduled = new Map(
|
|
1063
|
+
competitions.map(competition => [
|
|
1064
|
+
competition.id,
|
|
1065
|
+
new Set(
|
|
1066
|
+
(competition.sessions ?? [])
|
|
1067
|
+
.filter(session => session.phase === 'knockout')
|
|
1068
|
+
.map(session => session.date)
|
|
1069
|
+
),
|
|
1070
|
+
])
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
const spilled = blocks.filter(block => {
|
|
1074
|
+
if (block.phase !== 'knockout') {
|
|
1075
|
+
return false
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const days = scheduled.get(block.competition_id)
|
|
1079
|
+
|
|
1080
|
+
return days != null && days.size > 0 && !days.has(block.date)
|
|
1081
|
+
}).length
|
|
1082
|
+
|
|
1083
|
+
const missing = shortfall
|
|
1084
|
+
.filter(entry => entry.phase === 'knockout')
|
|
1085
|
+
.reduce((total, entry) => total + entry.missing, 0)
|
|
1086
|
+
|
|
1087
|
+
return spilled + missing
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function groupKey(competitionId, date) {
|
|
1091
|
+
return `${competitionId}:${date}`
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Hands out one court per eligible entry per pass, looping until
|
|
1097
|
+
* either the court list or every entry's remaining need is exhausted -
|
|
1098
|
+
* so a lone eligible competition gets every court offered, and several
|
|
1099
|
+
* eligible competitions split them round by round in priority order.
|
|
1100
|
+
*
|
|
1101
|
+
* A group-phase entry claims a whole run of consecutive slots on one
|
|
1102
|
+
* court at once (group_run_length - a 5-team group's 5 matches), and
|
|
1103
|
+
* only on a court that can host every slot of that run. A court that
|
|
1104
|
+
* runs out of light partway through cannot host the group at all, so
|
|
1105
|
+
* it is never given the run's opening slot either.
|
|
1106
|
+
*/
|
|
1107
|
+
/**
|
|
1108
|
+
* Which queued run belongs on a court that has already been taken.
|
|
1109
|
+
*
|
|
1110
|
+
* The court chose itself by being the lowest free one that fits the
|
|
1111
|
+
* whole run; this asks the planner which run should have it, scored on
|
|
1112
|
+
* which division is owed a run and whose band this court falls in -
|
|
1113
|
+
* hence the court's position along the row rather than its id. The row
|
|
1114
|
+
* is the courts playable in this slot, so at night the position is
|
|
1115
|
+
* measured against the lit ones alone.
|
|
1116
|
+
*
|
|
1117
|
+
* @param {Object} params
|
|
1118
|
+
* @param {string} params.phase Only a group phase has a queue at all.
|
|
1119
|
+
* @param {Array} params.usable Courts playable in this slot, in order.
|
|
1120
|
+
* @param {boolean} params.proCourt Whether the court taken is a pro
|
|
1121
|
+
* one. A pro court takes a pro division's run and nothing else, and
|
|
1122
|
+
* a pro run waits for one rather than taking an open court, so a
|
|
1123
|
+
* mismatch comes back blocked and the court is left empty.
|
|
1124
|
+
* @returns {?Object} The chosen run, {blocked: true}, or null where
|
|
1125
|
+
* the phase has no queue or the queue is empty.
|
|
1126
|
+
*/
|
|
1127
|
+
function pickRun({
|
|
1128
|
+
planner, phase, competition, date, sectionIndex, court, usable, proCourt,
|
|
1129
|
+
}) {
|
|
1130
|
+
if (phase !== 'group') {
|
|
1131
|
+
return null
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return planner.pick(competition.id, date, sectionIndex, {
|
|
1135
|
+
courtPosition: usable.indexOf(court),
|
|
1136
|
+
courtCount: usable.length,
|
|
1137
|
+
}, { proCourt })
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function assignCourts(courtList, eligible, context) {
|
|
1141
|
+
const { date, taken, blocks, remainingFor, decrement, fiveTeam } = context
|
|
1142
|
+
|
|
1143
|
+
let progressed = true
|
|
1144
|
+
|
|
1145
|
+
while (progressed) {
|
|
1146
|
+
progressed = false
|
|
1147
|
+
|
|
1148
|
+
for (const entry of eligible) {
|
|
1149
|
+
const { competition, phase, slots, slotIndex } = entry
|
|
1150
|
+
const slot = slots[slotIndex]
|
|
1151
|
+
|
|
1152
|
+
const available = courtList.filter(court =>
|
|
1153
|
+
courtUsableAt(court, slot) && taken.free(
|
|
1154
|
+
date, court.id, slot.start_minutes, slot.end_minutes
|
|
1155
|
+
)
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
if (available.length === 0) {
|
|
1159
|
+
continue
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const remaining = remainingFor(competition.id, phase, date)
|
|
1163
|
+
|
|
1164
|
+
if (remaining <= 0) {
|
|
1165
|
+
continue
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const planner = fiveTeam
|
|
1169
|
+
const runLength = phase === 'group'
|
|
1170
|
+
? planner.runLengthFor(competition.id, date)
|
|
1171
|
+
: 1
|
|
1172
|
+
|
|
1173
|
+
if (slotIndex % runLength !== 0) {
|
|
1174
|
+
continue
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const sectionIndex = Math.floor(slotIndex / runLength)
|
|
1178
|
+
|
|
1179
|
+
function fits(court) {
|
|
1180
|
+
return runFits(court, { ...context, slots, slotIndex }, runLength)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const courtPosition = available.findIndex(fits)
|
|
1184
|
+
|
|
1185
|
+
if (courtPosition === -1) {
|
|
1186
|
+
continue
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const [court] = available.splice(courtPosition, 1)
|
|
1190
|
+
|
|
1191
|
+
const picked = pickRun({
|
|
1192
|
+
planner, phase, competition, date, sectionIndex, court,
|
|
1193
|
+
usable: courtList.filter(court => courtUsableAt(court, slot)),
|
|
1194
|
+
proCourt: context.proCourt === true,
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
if (picked?.blocked || (phase === 'group' && !picked)) {
|
|
1198
|
+
continue
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const run = picked
|
|
1202
|
+
? planner.commit(competition.id, date, sectionIndex, picked.index)
|
|
1203
|
+
: null
|
|
1204
|
+
|
|
1205
|
+
for (let i = 0; i < Math.min(runLength, remaining); i++) {
|
|
1206
|
+
const runSlot = slots[slotIndex + i]
|
|
1207
|
+
|
|
1208
|
+
const matchSlot = run ? (run.positions[i] ?? null) : null
|
|
1209
|
+
|
|
1210
|
+
blocks.push({
|
|
1211
|
+
competition_id: competition.id,
|
|
1212
|
+
division_id: run?.divisionId ?? null,
|
|
1213
|
+
division_name: run?.divisionName ?? null,
|
|
1214
|
+
division_gender: run?.divisionGender ?? null,
|
|
1215
|
+
group_id: run?.groupId ?? null,
|
|
1216
|
+
group_name: run?.groupName ?? null,
|
|
1217
|
+
phase,
|
|
1218
|
+
round: phase === 'group' ? 'group' : null,
|
|
1219
|
+
match_slot: matchSlot,
|
|
1220
|
+
court_id: court.id,
|
|
1221
|
+
date,
|
|
1222
|
+
slot: runSlot.key,
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
taken.take(date, court.id, runSlot.start_minutes, runSlot.end_minutes)
|
|
1226
|
+
decrement(competition.id, phase, date)
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
progressed = true
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* When a slot starts and ends, in minutes past midnight.
|
|
1236
|
+
*
|
|
1237
|
+
* The server sends these, but a slot may arrive as nothing more than a
|
|
1238
|
+
* key ("09:00"), so they are derived where missing: a slot runs until
|
|
1239
|
+
* the next one starts, and the last runs a match's length.
|
|
1240
|
+
*/
|
|
1241
|
+
function withMinutes(slots) {
|
|
1242
|
+
return slots.map((slot, index) => {
|
|
1243
|
+
const start = slot.start_minutes ?? timeToMinutes(slot.key)
|
|
1244
|
+
const next = slots[index + 1]
|
|
1245
|
+
const end = slot.end_minutes
|
|
1246
|
+
?? (next
|
|
1247
|
+
? (next.start_minutes ?? timeToMinutes(next.key))
|
|
1248
|
+
: start + MATCH_MINUTES)
|
|
1249
|
+
|
|
1250
|
+
return { ...slot, start_minutes: start, end_minutes: end }
|
|
1251
|
+
})
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
export function slotsForDate(
|
|
1256
|
+
sessions, date, darkFromMinutes, dayEnd = 24 * 60, anchor = null
|
|
1257
|
+
) {
|
|
1258
|
+
const active = activeSession(sessions, date)
|
|
1259
|
+
|
|
1260
|
+
if (!active) {
|
|
1261
|
+
return []
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const length = Math.max(1, active.playtime ?? MATCH_MINUTES)
|
|
1265
|
+
const opens = timeToMinutes(active.start_time)
|
|
1266
|
+
|
|
1267
|
+
const from = anchor == null || anchor > opens
|
|
1268
|
+
? opens
|
|
1269
|
+
: anchor + Math.ceil((opens - anchor) / length) * length
|
|
1270
|
+
|
|
1271
|
+
const slots = []
|
|
1272
|
+
|
|
1273
|
+
for (let start = from; start + length <= dayEnd; start += length) {
|
|
1274
|
+
slots.push({
|
|
1275
|
+
key: minutesToTime(start),
|
|
1276
|
+
start_minutes: start,
|
|
1277
|
+
end_minutes: start + length,
|
|
1278
|
+
lit_required: darkFromMinutes != null && start + length > darkFromMinutes,
|
|
1279
|
+
phase: active.phase,
|
|
1280
|
+
})
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
return slots
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function minutesToTime(minutes) {
|
|
1287
|
+
const hours = Math.floor(minutes / 60)
|
|
1288
|
+
return `${String(hours).padStart(2, '0')}`
|
|
1289
|
+
+ `:${String(minutes % 60).padStart(2, '0')}`
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function timeToMinutes(key) {
|
|
1293
|
+
const [hours, minutes] = String(key).split(':').map(Number)
|
|
1294
|
+
return hours * 60 + (minutes || 0)
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
export { activeSession }
|