@opndev/tennis-match-center 0.0.2 → 0.0.4
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 +66 -1
- package/lib/auto-planner.mjs +1280 -601
- package/lib/components/day-grid.mjs +19 -7
- package/lib/components/gameday-board.mjs +111 -13
- package/lib/components/gameday-card.mjs +31 -0
- package/lib/components/match.mjs +17 -0
- package/lib/components/planning-board.mjs +233 -8
- package/lib/components/roster-board.mjs +42 -2
- package/lib/components/schedule-board.mjs +10 -6
- package/lib/court-bands.mjs +80 -1
- package/lib/four-team-planner.mjs +2 -2
- package/lib/index.mjs +1 -1
- package/lib/knockout-draw.mjs +32 -6
- package/lib/knockout-planner.mjs +12 -4
- package/lib/paired-court-planner.mjs +65 -2
- package/lib/planning-day.mjs +165 -0
- package/lib/run-queue-planner.mjs +167 -11
- package/lib/sessions.mjs +34 -0
- package/lib/three-team-planner.mjs +2 -1
- package/package.json +1 -1
package/lib/auto-planner.mjs
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
import { bandDistance, computeSectionBands } from './court-bands.mjs'
|
|
6
6
|
import { courtUsableAt, runFits } from './court-runs.mjs'
|
|
7
7
|
import {
|
|
8
|
-
fourTeamUnitLayout, fourTeamUnitSizes,
|
|
8
|
+
fourTeamUnitLayout, fourTeamUnitSizes,
|
|
9
9
|
} from './four-team-planner.mjs'
|
|
10
10
|
import { groupRuns } from './group-schedule.mjs'
|
|
11
11
|
import { wavesForDivision } from './knockout-waves.mjs'
|
|
12
12
|
import { makeFiveTeamPlanner } from './five-team-planner.mjs'
|
|
13
|
+
import { remainderOf } from './run-queue-planner.mjs'
|
|
13
14
|
import { threeTeamUnitLayout, threeTeamUnits } from './three-team-planner.mjs'
|
|
14
15
|
import { makeOccupancy, courtCanHost } from './occupancy.mjs'
|
|
15
16
|
import { assignKnockout } from './knockout-planner.mjs'
|
|
@@ -19,6 +20,37 @@ import { PlanningDay } from './planning-day.mjs'
|
|
|
19
20
|
import { activePhase, activeSession } from './sessions.mjs'
|
|
20
21
|
import { MATCH_MINUTES } from './match-time.mjs'
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Every day the competitions say they play on: the dates of their
|
|
25
|
+
* groups and knockouts, and of their sessions.
|
|
26
|
+
*
|
|
27
|
+
* @param {Array} competitions
|
|
28
|
+
* @returns {string[]} sorted, once each
|
|
29
|
+
*/
|
|
30
|
+
export function eventDays(competitions) {
|
|
31
|
+
const days = new Set()
|
|
32
|
+
|
|
33
|
+
for (const competition of competitions ?? []) {
|
|
34
|
+
if (competition.available_for_planning === false) {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const date of competition.group_dates ?? []) {
|
|
39
|
+
days.add(date)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const date of competition.knockout_dates ?? []) {
|
|
43
|
+
days.add(date)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const session of competition.sessions ?? []) {
|
|
47
|
+
days.add(session.date)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return [...days].sort()
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
/**
|
|
23
55
|
* Automatically fills the Planning grid (which competition gets which
|
|
24
56
|
* court/hour cell) instead of a director dragging out every cell by
|
|
@@ -86,142 +118,1017 @@ import { MATCH_MINUTES } from './match-time.mjs'
|
|
|
86
118
|
const GAMES_PER_POOL_SIZE = { 3: 3, 4: 6, 5: 10 }
|
|
87
119
|
|
|
88
120
|
export function autoFillPlanning(params) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
const strict = planOnce(params)
|
|
122
|
+
|
|
123
|
+
if (!params.relaxed || params.sharing || groupShortfall(strict) === 0) {
|
|
124
|
+
return strict
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The strict rules could not place everything, and the caller has said
|
|
128
|
+
// some of them may be broken: plan again with the courts shared.
|
|
129
|
+
const shared = planOnce({ ...params, sharing: true })
|
|
130
|
+
const better = groupShortfall(shared) < groupShortfall(strict) ? shared : strict
|
|
131
|
+
|
|
132
|
+
// Sharing is built for groups of five. A competition still short that
|
|
133
|
+
// had no section to share is not short for want of trying.
|
|
134
|
+
const unshared = [...new Set(
|
|
135
|
+
better.shortfall
|
|
136
|
+
.filter(entry => entry.phase === 'group' && entry.missing > 0)
|
|
137
|
+
.map(entry => entry.competition_id)
|
|
138
|
+
)].filter(id => !shared.shareable.includes(id))
|
|
139
|
+
|
|
140
|
+
return unshared.length === 0
|
|
141
|
+
? better
|
|
142
|
+
: {
|
|
143
|
+
...better,
|
|
144
|
+
warnings: [
|
|
145
|
+
...(better.warnings ?? []),
|
|
146
|
+
...unshared.map(id => ({ competition_id: id, kind: 'not-shareable' })),
|
|
147
|
+
],
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* How many group matches a result could not place.
|
|
153
|
+
*
|
|
154
|
+
* @param {Object} result
|
|
155
|
+
* @returns {number}
|
|
156
|
+
*/
|
|
157
|
+
function groupShortfall(result) {
|
|
158
|
+
return (result.shortfall ?? [])
|
|
159
|
+
.filter(entry => entry.phase === 'group')
|
|
160
|
+
.reduce((total, entry) => total + (entry.missing ?? 0), 0)
|
|
161
|
+
}
|
|
129
162
|
|
|
163
|
+
function planOnce(params) {
|
|
164
|
+
const planner = new AutoPlanner(params)
|
|
165
|
+
const result = planner.fill()
|
|
166
|
+
const overflow = knockoutOverflow(planner.competitions, result)
|
|
167
|
+
|
|
168
|
+
if (planner.splitRounds || overflow === 0) {
|
|
169
|
+
return result
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const retried = planOnce({ ...params, splitRounds: true })
|
|
173
|
+
|
|
174
|
+
return knockoutOverflow(planner.competitions, retried) < overflow
|
|
175
|
+
? retried
|
|
176
|
+
: result
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One run of the planner: what the event looks like, set up once, and
|
|
181
|
+
* the days filled in order.
|
|
182
|
+
*/
|
|
183
|
+
class AutoPlanner {
|
|
130
184
|
|
|
131
185
|
/**
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
186
|
+
* @param {Object} params What autoFillPlanning takes.
|
|
187
|
+
*/
|
|
188
|
+
constructor(params) {
|
|
189
|
+
const {
|
|
190
|
+
courts,
|
|
191
|
+
slots: rawSlots,
|
|
192
|
+
days: namedDays,
|
|
193
|
+
competitions: allCompetitions,
|
|
194
|
+
registrationClosed = false,
|
|
195
|
+
alwaysByeShortGroups = false,
|
|
196
|
+
courtOverrides = [],
|
|
197
|
+
darkFrom = null,
|
|
198
|
+
dayEnd = '24:00',
|
|
199
|
+
lightBuffer = undefined,
|
|
200
|
+
groupSlotsFromSessions = false,
|
|
201
|
+
splitRounds = false,
|
|
202
|
+
sharing = false,
|
|
203
|
+
// Cells the plan already holds, for a canvas smaller than the
|
|
204
|
+
// event: what they hold is work this run does not owe, and the
|
|
205
|
+
// courts and hours they sit on are taken.
|
|
206
|
+
standing = [],
|
|
207
|
+
} = params
|
|
208
|
+
|
|
209
|
+
const rules = { registrationClosed, alwaysByeShortGroups }
|
|
210
|
+
|
|
211
|
+
const days = namedDays ?? eventDays(allCompetitions)
|
|
212
|
+
|
|
213
|
+
const competitions = allCompetitions
|
|
214
|
+
.filter(competition => competition.available_for_planning !== false)
|
|
215
|
+
.map(competition => ({
|
|
216
|
+
...competition,
|
|
217
|
+
groups: (competition.groups ?? []).map(group => ({
|
|
218
|
+
...group,
|
|
219
|
+
team_ids: group.team_ids
|
|
220
|
+
?? Array.from({ length: group.team_count ?? 0 }, (_, i) => i),
|
|
221
|
+
})),
|
|
222
|
+
}))
|
|
223
|
+
|
|
224
|
+
const slots = withMinutes(rawSlots ?? [])
|
|
225
|
+
const darkFromMinutes = darkFrom == null ? null : timeToMinutes(darkFrom)
|
|
226
|
+
|
|
227
|
+
const dayEndMinutes = timeToMinutes(dayEnd)
|
|
228
|
+
const plan = new Plan()
|
|
229
|
+
const blocks = plan.blocks
|
|
230
|
+
const remainingGroupByDate = new Map()
|
|
231
|
+
|
|
232
|
+
// What the saved plan already holds, for a canvas that is not the
|
|
233
|
+
// whole event: a fill of one day is handed the other days' cells
|
|
234
|
+
// so it plans what is left rather than the draw from the top.
|
|
235
|
+
const alreadyPlaced = new Map()
|
|
236
|
+
|
|
237
|
+
// A group's fixtures are known one by one - a cell carries the
|
|
238
|
+
// match_slot it was booked for - so what is left is the template
|
|
239
|
+
// minus those, holes and all. The knockout only knows counts.
|
|
240
|
+
const playedFixtures = new Map()
|
|
241
|
+
|
|
242
|
+
for (const block of standing) {
|
|
243
|
+
if (block.phase === 'group') {
|
|
244
|
+
if (block.group_id != null && block.match_slot != null) {
|
|
245
|
+
const slots = playedFixtures.get(block.group_id) ?? new Set()
|
|
246
|
+
|
|
247
|
+
slots.add(block.match_slot)
|
|
248
|
+
playedFixtures.set(block.group_id, slots)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (block.round == null) {
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const key = [block.competition_id, block.division_id ?? null, block.round].join('|')
|
|
259
|
+
|
|
260
|
+
alreadyPlaced.set(key, (alreadyPlaced.get(key) ?? 0) + 1)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const fiveTeam = makeFiveTeamPlanner()
|
|
264
|
+
const fourTeamGroups = []
|
|
265
|
+
const threeTeamGroups = []
|
|
266
|
+
const boltOnGroups = []
|
|
267
|
+
const noTemplateWarned = new Set()
|
|
268
|
+
let fourTeamUnits = null
|
|
269
|
+
let builtThreeTeamUnits = null
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
const knockoutUnits = []
|
|
273
|
+
|
|
274
|
+
const proDivisions = new Set()
|
|
275
|
+
|
|
276
|
+
for (const competition of competitions) {
|
|
277
|
+
for (const group of competition.groups ?? []) {
|
|
278
|
+
if (group.is_pro === true && group.division_id != null) {
|
|
279
|
+
proDivisions.add(group.division_id)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const sortedSessions = new Map()
|
|
285
|
+
for (const competition of competitions) {
|
|
286
|
+
sortedSessions.set(
|
|
287
|
+
competition.id,
|
|
288
|
+
[...competition.sessions].sort((a, b) =>
|
|
289
|
+
(a.date + a.start_time).localeCompare(b.date + b.start_time)
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const canvas = {
|
|
295
|
+
sortedSessions, days, darkFromMinutes, dayEndMinutes,
|
|
296
|
+
competitions, knockoutUnits, groupSlotsFromSessions, slots,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
Object.assign(this, {
|
|
300
|
+
courts,
|
|
301
|
+
rawSlots,
|
|
302
|
+
namedDays,
|
|
303
|
+
allCompetitions,
|
|
304
|
+
registrationClosed,
|
|
305
|
+
alwaysByeShortGroups,
|
|
306
|
+
courtOverrides,
|
|
307
|
+
darkFrom,
|
|
308
|
+
dayEnd,
|
|
309
|
+
lightBuffer,
|
|
310
|
+
groupSlotsFromSessions,
|
|
311
|
+
splitRounds,
|
|
312
|
+
sharing,
|
|
313
|
+
standing,
|
|
314
|
+
rules,
|
|
315
|
+
days,
|
|
316
|
+
competitions,
|
|
317
|
+
slots,
|
|
318
|
+
darkFromMinutes,
|
|
319
|
+
dayEndMinutes,
|
|
320
|
+
plan,
|
|
321
|
+
blocks,
|
|
322
|
+
remainingGroupByDate,
|
|
323
|
+
alreadyPlaced,
|
|
324
|
+
playedFixtures,
|
|
325
|
+
fiveTeam,
|
|
326
|
+
fourTeamGroups,
|
|
327
|
+
threeTeamGroups,
|
|
328
|
+
boltOnGroups,
|
|
329
|
+
noTemplateWarned,
|
|
330
|
+
fourTeamUnits,
|
|
331
|
+
builtThreeTeamUnits,
|
|
332
|
+
knockoutUnits,
|
|
333
|
+
proDivisions,
|
|
334
|
+
sortedSessions,
|
|
335
|
+
canvas,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
for (const competition of competitions) {
|
|
339
|
+
const derived = (competition.divisions ?? [])
|
|
340
|
+
.filter(division =>
|
|
341
|
+
division.knockout_matches != null || division.team_count != null
|
|
342
|
+
)
|
|
343
|
+
.map(division => ({
|
|
344
|
+
division_id: division.id,
|
|
345
|
+
division_name: division.name,
|
|
346
|
+
division_gender: division.gender ?? null,
|
|
347
|
+
level: division.level ?? 0,
|
|
348
|
+
waves: wavesForDivision(division, { itf: competition.itf === true }),
|
|
349
|
+
}))
|
|
350
|
+
.filter(bracket => bracket.waves.length > 0)
|
|
351
|
+
|
|
352
|
+
const brackets = derived.length > 0
|
|
353
|
+
? derived
|
|
354
|
+
: (competition.knockout_rounds_by_division
|
|
355
|
+
?? (competition.games_needed_by_round?.length
|
|
356
|
+
? [competition.games_needed_by_round]
|
|
357
|
+
: []))
|
|
358
|
+
|
|
359
|
+
for (const bracket of brackets) {
|
|
360
|
+
const division = Array.isArray(bracket) ? null : bracket
|
|
361
|
+
const waves = this.owedWaves(
|
|
362
|
+
Array.isArray(bracket) ? bracket : bracket.waves,
|
|
363
|
+
competition.id,
|
|
364
|
+
division?.division_id ?? null
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if (waves.length === 0) {
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
knockoutUnits.push(new KnockoutDraw({
|
|
372
|
+
competition,
|
|
373
|
+
waves,
|
|
374
|
+
divisionId: division?.division_id ?? null,
|
|
375
|
+
divisionName: division?.division_name ?? null,
|
|
376
|
+
divisionGender: division?.division_gender ?? null,
|
|
377
|
+
level: division?.level ?? 0,
|
|
378
|
+
isPro: proDivisions.has(division?.division_id),
|
|
379
|
+
}))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (Array.isArray(competition.groups) && competition.groups.length === 0) {
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const boltOn = this.pooledLeads(competition)
|
|
387
|
+
|
|
388
|
+
for (const group of competition.groups ?? []) {
|
|
389
|
+
if (this.shortGroup(group)) {
|
|
390
|
+
const size = group.pool_size ?? group.team_ids.length
|
|
391
|
+
const asFull = alwaysByeShortGroups || !registrationClosed
|
|
392
|
+
const padded = asFull && size > group.team_ids.length
|
|
393
|
+
? paddedWithByes(group, size)
|
|
394
|
+
: group
|
|
395
|
+
|
|
396
|
+
fourTeamGroups.push({ competition, group: padded })
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if ((group.team_ids ?? []).length === 3) {
|
|
401
|
+
threeTeamGroups.push({ competition, group, placed: new Set() })
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (boltOn) {
|
|
406
|
+
boltOnGroups.push({ competition, group, placed: new Set() })
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
for (const { date, count } of fiveTeam.enqueue({
|
|
411
|
+
competition,
|
|
412
|
+
group,
|
|
413
|
+
dates: this.groupDatesFor(competition),
|
|
414
|
+
asFull: alwaysByeShortGroups || !registrationClosed,
|
|
415
|
+
done: this.fixtureDone.bind(this),
|
|
416
|
+
})) {
|
|
417
|
+
const key = groupKey(competition.id, date)
|
|
418
|
+
remainingGroupByDate.set(
|
|
419
|
+
key, (remainingGroupByDate.get(key) ?? 0) + count
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const activeCourts = courts.filter(court => court.active)
|
|
426
|
+
const proCourts = activeCourts.filter(court => court.pro)
|
|
427
|
+
const openCourts = activeCourts.filter(court => !court.pro)
|
|
428
|
+
|
|
429
|
+
const overridesByDate = new Map()
|
|
430
|
+
|
|
431
|
+
for (const override of courtOverrides) {
|
|
432
|
+
if (!overridesByDate.has(override.date)) {
|
|
433
|
+
overridesByDate.set(override.date, new Map())
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
overridesByDate.get(override.date).set(
|
|
437
|
+
override.court_id,
|
|
438
|
+
override.available_from ? timeToMinutes(override.available_from) : null
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const venue = { overridesByDate, openCourts, proCourts, activeCourts }
|
|
443
|
+
|
|
444
|
+
Object.assign(this, {
|
|
445
|
+
activeCourts,
|
|
446
|
+
proCourts,
|
|
447
|
+
openCourts,
|
|
448
|
+
overridesByDate,
|
|
449
|
+
venue,
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
fiveTeam.prepare({
|
|
453
|
+
slotsFor: this.slotsFor.bind(this),
|
|
454
|
+
courts: openCourts,
|
|
455
|
+
usable: courtUsableAt,
|
|
456
|
+
sharing,
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Fills every day of the canvas and says what did not fit.
|
|
139
462
|
*
|
|
140
|
-
*
|
|
141
|
-
* Doubles is mostly fives, so its handful of fours fill in around
|
|
142
|
-
* them. Singles is the other way round, and planning its one 5-team
|
|
143
|
-
* group first left a court with four slots nothing could use, since
|
|
144
|
-
* nothing in the tournament is four slots long.
|
|
463
|
+
* @returns {Object} the plan's result
|
|
145
464
|
*/
|
|
146
|
-
|
|
147
|
-
let
|
|
148
|
-
|
|
465
|
+
fill() {
|
|
466
|
+
let {
|
|
467
|
+
courts,
|
|
468
|
+
registrationClosed,
|
|
469
|
+
alwaysByeShortGroups,
|
|
470
|
+
lightBuffer,
|
|
471
|
+
groupSlotsFromSessions,
|
|
472
|
+
splitRounds,
|
|
473
|
+
standing,
|
|
474
|
+
days,
|
|
475
|
+
competitions,
|
|
476
|
+
slots,
|
|
477
|
+
darkFromMinutes,
|
|
478
|
+
plan,
|
|
479
|
+
blocks,
|
|
480
|
+
remainingGroupByDate,
|
|
481
|
+
fiveTeam,
|
|
482
|
+
threeTeamGroups,
|
|
483
|
+
boltOnGroups,
|
|
484
|
+
noTemplateWarned,
|
|
485
|
+
fourTeamUnits,
|
|
486
|
+
builtThreeTeamUnits,
|
|
487
|
+
knockoutUnits,
|
|
488
|
+
sortedSessions,
|
|
489
|
+
canvas,
|
|
490
|
+
proCourts,
|
|
491
|
+
openCourts,
|
|
492
|
+
overridesByDate,
|
|
493
|
+
} = this
|
|
494
|
+
|
|
495
|
+
for (const date of days) {
|
|
496
|
+
const taken = makeOccupancy()
|
|
497
|
+
|
|
498
|
+
// Cells the plan already holds on this day - what was played
|
|
499
|
+
// there. The canvas keeps them: their courts are gone for those
|
|
500
|
+
// hours, and the work in them is work this run does not owe.
|
|
501
|
+
for (const block of standing) {
|
|
502
|
+
if (block.date !== date) {
|
|
503
|
+
continue
|
|
504
|
+
}
|
|
149
505
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
506
|
+
const slot = (slots.length > 0 ? slots : []).find(one => one.key === block.slot)
|
|
507
|
+
const start = slot?.start_minutes ?? timeToMinutes(block.slot)
|
|
508
|
+
const end = slot?.end_minutes ?? start + MATCH_MINUTES
|
|
153
509
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
510
|
+
taken.take(date, block.court_id, start, end)
|
|
511
|
+
plan.place(block)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const slotsByCompetition = new Map(
|
|
515
|
+
competitions.map(competition => [
|
|
516
|
+
competition.id,
|
|
517
|
+
competition.slots
|
|
518
|
+
? withMinutes(competition.slots)
|
|
519
|
+
: ((groupSlotsFromSessions || slots.length === 0)
|
|
520
|
+
? this.dayFor(competition, date)
|
|
521
|
+
: slots),
|
|
522
|
+
])
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
const day = new PlanningDay({
|
|
526
|
+
date,
|
|
527
|
+
proCourts,
|
|
528
|
+
openCourts: this.openCourtsFor(date),
|
|
529
|
+
slotsByCompetition,
|
|
530
|
+
occupancy: taken,
|
|
531
|
+
plan,
|
|
532
|
+
darkFromMinutes,
|
|
533
|
+
lightBufferMinutes: lightBuffer,
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
if (this.sharing) {
|
|
537
|
+
this.assignShared(day)
|
|
158
538
|
}
|
|
539
|
+
|
|
540
|
+
const timeline = []
|
|
541
|
+
|
|
542
|
+
for (const competition of competitions) {
|
|
543
|
+
const own = slotsByCompetition.get(competition.id) ?? []
|
|
544
|
+
|
|
545
|
+
const runLength = fiveTeam.runLengthFor(competition.id, date)
|
|
546
|
+
|
|
547
|
+
own.forEach((slot, slotIndex) => {
|
|
548
|
+
const phase = slot.phase
|
|
549
|
+
?? activePhase(sortedSessions.get(competition.id), date, slot.key)
|
|
550
|
+
|
|
551
|
+
if (phase !== 'group') {
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const last = own[Math.min(slotIndex + runLength - 1, own.length - 1)]
|
|
556
|
+
const needsLight = runNeedsLight(
|
|
557
|
+
own, slotIndex, runLength, darkFromMinutes
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
timeline.push({
|
|
561
|
+
competition, phase: 'group', slots: own, slotIndex, slot, needsLight,
|
|
562
|
+
})
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const lit = byStartTime(timeline.filter(e => e.needsLight), true)
|
|
567
|
+
|
|
568
|
+
for (const [, entries] of lit) {
|
|
569
|
+
this.assign(entries, true, day)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const daylight = byStartTime(timeline.filter(e => !e.needsLight), false)
|
|
573
|
+
|
|
574
|
+
for (const [, entries] of daylight) {
|
|
575
|
+
this.assign(entries, false, day)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
fourTeamUnits ??= this.buildFourTeamUnits()
|
|
579
|
+
day.placeGroupUnits(
|
|
580
|
+
fourTeamUnits, fourTeamUnitLayout, slots,
|
|
581
|
+
this.fixtureDone.bind(this)
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
builtThreeTeamUnits ??= this.buildThreeTeamUnits()
|
|
585
|
+
day.placeGroupUnits(
|
|
586
|
+
builtThreeTeamUnits, threeTeamUnitLayout, slots,
|
|
587
|
+
this.fixtureDone.bind(this)
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
for (const entry of boltOnGroups) {
|
|
591
|
+
const opened = this.groupDatesFor(entry.competition).includes(date)
|
|
592
|
+
|
|
593
|
+
if (entry.placed.has(date) || !opened) {
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const own = slotsByCompetition.get(entry.competition.id) ?? []
|
|
598
|
+
|
|
599
|
+
if (own.length === 0 || own[0].phase === 'knockout') {
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const dates = this.groupDatesFor(entry.competition)
|
|
604
|
+
const teamIds = entry.group.team_ids ?? []
|
|
605
|
+
const size = entry.group.pool_size ?? teamIds.length
|
|
606
|
+
|
|
607
|
+
const asFull = alwaysByeShortGroups || !registrationClosed
|
|
608
|
+
const seats = asFull && size > teamIds.length
|
|
609
|
+
? Array.from({ length: size }, (_, i) => teamIds[i] ?? -(i + 1))
|
|
610
|
+
: teamIds
|
|
611
|
+
|
|
612
|
+
const runs = groupRuns({ teamIds: seats, groupSize: size })
|
|
613
|
+
|
|
614
|
+
if (runs.length === 0 && teamIds.length > 0
|
|
615
|
+
&& !noTemplateWarned.has(entry.group.id)) {
|
|
616
|
+
noTemplateWarned.add(entry.group.id)
|
|
617
|
+
|
|
618
|
+
plan.warn({
|
|
619
|
+
competition_id: entry.competition.id,
|
|
620
|
+
kind: 'no-template',
|
|
621
|
+
division_id: entry.group.division_id ?? null,
|
|
622
|
+
division_name: entry.group.division_name ?? null,
|
|
623
|
+
group_id: entry.group.id,
|
|
624
|
+
group_name: entry.group.name ?? null,
|
|
625
|
+
teams: teamIds.length,
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
plan.short(
|
|
629
|
+
entry.competition, 'group',
|
|
630
|
+
GAMES_PER_POOL_SIZE[entry.group.pool_size] ?? 6
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const today = runs.filter(run =>
|
|
635
|
+
(dates.length === 1 ? dates[0] : dates[run.day - 1]) === date
|
|
636
|
+
)
|
|
637
|
+
const cells = today
|
|
638
|
+
.map(run => remainderOf(
|
|
639
|
+
run, entry.group.id, this.fixtureDone.bind(this)
|
|
640
|
+
))
|
|
641
|
+
.filter(run => run !== null)
|
|
642
|
+
.flatMap(run => run.positions)
|
|
643
|
+
|
|
644
|
+
if (cells.length === 0) {
|
|
645
|
+
continue
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const laneCourts = entry.group.is_pro === true
|
|
649
|
+
? proCourts
|
|
650
|
+
: this.openCourtsFor(date)
|
|
651
|
+
let landed = false
|
|
652
|
+
|
|
653
|
+
const last = own.length - cells.length
|
|
654
|
+
|
|
655
|
+
for (let start = 0; start <= last && !landed; start++) {
|
|
656
|
+
const window = own.slice(start, start + cells.length)
|
|
657
|
+
|
|
658
|
+
for (const court of laneCourts) {
|
|
659
|
+
const usable = window.every(slot =>
|
|
660
|
+
courtUsableAt(court, slot)
|
|
661
|
+
&& taken.free(
|
|
662
|
+
date, court.id, slot.start_minutes, slot.end_minutes
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if (!usable) {
|
|
667
|
+
continue
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
window.forEach((slot, offset) => {
|
|
671
|
+
blocks.push({
|
|
672
|
+
competition_id: entry.competition.id,
|
|
673
|
+
division_id: entry.group.division_id ?? null,
|
|
674
|
+
division_name: entry.group.division_name ?? null,
|
|
675
|
+
division_gender: entry.group.division_gender ?? null,
|
|
676
|
+
group_id: entry.group.id,
|
|
677
|
+
group_name: entry.group.name ?? null,
|
|
678
|
+
phase: 'group',
|
|
679
|
+
round: 'group',
|
|
680
|
+
match_slot: cells[offset] ?? null,
|
|
681
|
+
court_id: court.id,
|
|
682
|
+
date,
|
|
683
|
+
slot: slot.key,
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
taken.take(date, court.id, slot.start_minutes, slot.end_minutes)
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
entry.placed.add(date)
|
|
690
|
+
landed = true
|
|
691
|
+
break
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!landed) {
|
|
696
|
+
plan.short(entry.competition, 'group', cells.length)
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
assignKnockout(day, {
|
|
701
|
+
competitions,
|
|
702
|
+
draws: knockoutUnits,
|
|
703
|
+
sessions: sortedSessions,
|
|
704
|
+
split: splitRounds,
|
|
705
|
+
})
|
|
159
706
|
}
|
|
160
707
|
|
|
161
|
-
|
|
708
|
+
refuseLateStarts({
|
|
709
|
+
competitions, sortedSessions, plan, overridesByDate,
|
|
710
|
+
slotsFor: this.slotsFor.bind(this),
|
|
711
|
+
runLengthFor: (id, date) => fiveTeam.runLengthFor(id, date),
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
const placed = plan.blocks.filter(block => !plan.dropped.has(block))
|
|
715
|
+
|
|
716
|
+
for (const entry of threeTeamGroups) {
|
|
717
|
+
const got = placed.filter(block =>
|
|
718
|
+
block.group_id === entry.group.id && block.phase === 'group'
|
|
719
|
+
).length
|
|
720
|
+
const missing = GAMES_PER_POOL_SIZE[3] - got
|
|
721
|
+
|
|
722
|
+
if (missing > 0) {
|
|
723
|
+
plan.short(entry.competition, 'group', missing)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
for (const [competitionId, count] of this.unplacedFourTeamMatches(fourTeamUnits ?? [])) {
|
|
728
|
+
plan.short(competitions.find(own => own.id === competitionId), 'group', count)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
for (const competition of competitions) {
|
|
732
|
+
if (competition.sessions.length === 0) {
|
|
733
|
+
// Nothing can be planned without a session, and a competition
|
|
734
|
+
// that owes matches should not just go quiet.
|
|
735
|
+
const owes = (competition.groups ?? []).length > 0
|
|
736
|
+
|| knockoutUnits.some(draw => draw.competition.id === competition.id)
|
|
737
|
+
|
|
738
|
+
if (owes) {
|
|
739
|
+
plan.warn({ competition_id: competition.id, kind: 'no-sessions' })
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// A session on a day the plan does not have can place nothing.
|
|
746
|
+
const outside = [...new Set(competition.sessions.map(session => session.date))]
|
|
747
|
+
.filter(date => !days.includes(date))
|
|
748
|
+
.sort()
|
|
749
|
+
|
|
750
|
+
if (outside.length > 0) {
|
|
751
|
+
plan.warn({
|
|
752
|
+
competition_id: competition.id, kind: 'outside-days', dates: outside,
|
|
753
|
+
})
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Only what this canvas was asked for. Handed one day, the
|
|
757
|
+
// planner is not short of the group play that belongs to Friday -
|
|
758
|
+
// it was never asked to place it, and saying so would bury the
|
|
759
|
+
// one number a director is looking for.
|
|
760
|
+
const canvas = new Set(days)
|
|
761
|
+
|
|
762
|
+
const missingGroup = [...remainingGroupByDate]
|
|
763
|
+
.filter(([key]) =>
|
|
764
|
+
key.slice(0, key.indexOf(':')) === String(competition.id)
|
|
765
|
+
&& canvas.has(key.slice(key.indexOf(':') + 1))
|
|
766
|
+
)
|
|
767
|
+
.reduce((total, left) => total + Math.max(left[1], 0), 0)
|
|
768
|
+
|
|
769
|
+
// A draw's remainder is only this canvas's problem when the
|
|
770
|
+
// canvas holds every day the draw had to play on. Otherwise what
|
|
771
|
+
// is left of it belongs to a day nobody asked about.
|
|
772
|
+
const ownsTheDraw = (competition.knockout_dates ?? [])
|
|
773
|
+
.every(date => canvas.has(date))
|
|
774
|
+
|
|
775
|
+
const missingKnockout = ownsTheDraw
|
|
776
|
+
? knockoutUnits
|
|
777
|
+
.filter(draw => draw.competition.id === competition.id)
|
|
778
|
+
.reduce((total, draw) => total + draw.unplanned, 0)
|
|
779
|
+
: 0
|
|
780
|
+
|
|
781
|
+
plan.short(competition, 'group', missingGroup)
|
|
782
|
+
plan.short(competition, 'knockout', missingKnockout)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const result = plan.result(competitions)
|
|
786
|
+
|
|
787
|
+
const shareable = this.sharing
|
|
788
|
+
? competitions
|
|
789
|
+
.filter(own => days.some(date => fiveTeam.sharedFor(own.id, date)))
|
|
790
|
+
.map(own => own.id)
|
|
791
|
+
: []
|
|
792
|
+
|
|
793
|
+
return Object.assign(result, { split: this.split ?? [], shareable })
|
|
162
794
|
}
|
|
163
795
|
|
|
164
796
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
797
|
+
* The matches of four-team units that were never laid out.
|
|
798
|
+
*
|
|
799
|
+
* A unit that found no room on a day it plays has placed nothing, and
|
|
800
|
+
* nothing else would say so. What it owes that day is its share of
|
|
801
|
+
* the unit's sequence, less what the plan already holds.
|
|
802
|
+
*
|
|
803
|
+
* @param {Array} units
|
|
804
|
+
* @returns {Map<number, number>} unplaced matches by competition
|
|
168
805
|
*/
|
|
806
|
+
unplacedFourTeamMatches(units) {
|
|
807
|
+
const missing = new Map()
|
|
808
|
+
const competitionOf = new Map()
|
|
809
|
+
const done = this.fixtureDone.bind(this)
|
|
810
|
+
|
|
811
|
+
for (const unit of units) {
|
|
812
|
+
const dates = unit.dates.slice(0, 2)
|
|
813
|
+
const groups = unit.entries.map(entry => ({
|
|
814
|
+
id: entry.group.id,
|
|
815
|
+
teamIds: entry.group.team_ids,
|
|
816
|
+
groupSize: entry.group.pool_size,
|
|
817
|
+
}))
|
|
818
|
+
|
|
819
|
+
for (const entry of unit.entries) {
|
|
820
|
+
competitionOf.set(entry.group.id, entry.competition.id)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const whole = fourTeamUnitLayout(groups, () => false)
|
|
824
|
+
const firstDay = Math.floor(whole.length / dates.length)
|
|
825
|
+
|
|
826
|
+
dates.forEach((date, dayIndex) => {
|
|
827
|
+
if (!this.days.includes(date) || unit.placed.has(date)) {
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const share = dayIndex === 0 ? whole.slice(0, firstDay) : whole.slice(firstDay)
|
|
832
|
+
|
|
833
|
+
for (const cell of share.flat().filter(Boolean)) {
|
|
834
|
+
if (!done(cell.groupId, cell.index)) {
|
|
835
|
+
const id = competitionOf.get(cell.groupId)
|
|
836
|
+
|
|
837
|
+
missing.set(id, (missing.get(id) ?? 0) + 1)
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return missing
|
|
844
|
+
}
|
|
845
|
+
|
|
169
846
|
/**
|
|
170
|
-
*
|
|
171
|
-
* one of its own.
|
|
847
|
+
* Plays a section on shared courts, for the competitions that have one.
|
|
172
848
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
849
|
+
* The competitions take turns at the courts that are free for the
|
|
850
|
+
* front of the run, each within its own quota, so every category
|
|
851
|
+
* keeps its share of the section and still opens when its session
|
|
852
|
+
* says. Each group plays the front of its run on the court it was
|
|
853
|
+
* given. The back goes to a court that lasts the whole section, two
|
|
854
|
+
* groups to a court: one in the first, third and fifth match slot and
|
|
855
|
+
* the other in the second, fourth and sixth, so neither waits longer
|
|
856
|
+
* than a match. Groups of a competition pair with each other first,
|
|
857
|
+
* in court order, so neighbours in a band share a court.
|
|
176
858
|
*
|
|
177
|
-
*
|
|
178
|
-
* for five. While registration is open it may still fill from the
|
|
179
|
-
* waitlist, so capacity is held for it as a five - the spare cells
|
|
180
|
-
* are the point, not waste. Once registration closes it is a 4-team
|
|
181
|
-
* group like any other.
|
|
859
|
+
* @param {PlanningDay} day
|
|
182
860
|
*/
|
|
183
|
-
|
|
184
|
-
const
|
|
861
|
+
assignShared(day) {
|
|
862
|
+
const { fiveTeam, competitions } = this
|
|
863
|
+
const { date } = day
|
|
185
864
|
|
|
186
|
-
|
|
187
|
-
|
|
865
|
+
this.split ??= []
|
|
866
|
+
|
|
867
|
+
const sharing = competitions
|
|
868
|
+
.map(competition => ({
|
|
869
|
+
competition,
|
|
870
|
+
shared: fiveTeam.sharedFor(competition.id, date),
|
|
871
|
+
}))
|
|
872
|
+
.filter(entry => entry.shared)
|
|
873
|
+
|
|
874
|
+
if (sharing.length === 0) {
|
|
875
|
+
return
|
|
188
876
|
}
|
|
189
877
|
|
|
190
|
-
|
|
191
|
-
|
|
878
|
+
for (const entry of sharing) {
|
|
879
|
+
const { shared, competition } = entry
|
|
880
|
+
const slots = day.slotsFor(competition.id)
|
|
881
|
+
|
|
882
|
+
entry.front = slots.slice(shared.first, shared.first + shared.front)
|
|
883
|
+
entry.back = slots.slice(
|
|
884
|
+
shared.first + shared.front, shared.first + shared.length
|
|
885
|
+
)
|
|
886
|
+
entry.turns = []
|
|
192
887
|
}
|
|
193
888
|
|
|
194
|
-
|
|
195
|
-
|
|
889
|
+
const used = new Set()
|
|
890
|
+
let progressed = true
|
|
891
|
+
|
|
892
|
+
while (progressed) {
|
|
893
|
+
progressed = false
|
|
894
|
+
|
|
895
|
+
for (const entry of sharing) {
|
|
896
|
+
const { competition, shared } = entry
|
|
897
|
+
|
|
898
|
+
const free = day.openCourts.filter(court =>
|
|
899
|
+
!used.has(court.id)
|
|
900
|
+
&& entry.front.every(slot => day.isFree(court, slot))
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
if (free.length === 0) {
|
|
904
|
+
continue
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const [court] = free
|
|
908
|
+
const choice = pickRun({
|
|
909
|
+
planner: fiveTeam, phase: 'group', competition, date,
|
|
910
|
+
sectionIndex: shared.index, court, usable: free, proCourt: false,
|
|
911
|
+
strictQuota: true,
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
if (!choice || choice.blocked) {
|
|
915
|
+
continue
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const run = fiveTeam.commit(
|
|
919
|
+
competition.id, date, shared.index, choice.index
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
run.courts = [court.id]
|
|
923
|
+
used.add(court.id)
|
|
924
|
+
entry.turns.push({ run, court })
|
|
925
|
+
this.placeMatches(day, competition, run, court, entry.front, 0)
|
|
926
|
+
progressed = true
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
this.shareBack(day, sharing)
|
|
196
931
|
}
|
|
197
932
|
|
|
198
|
-
|
|
199
|
-
*
|
|
933
|
+
/**
|
|
934
|
+
* Gives every group of a shared section the courts for the back of its
|
|
935
|
+
* run, and shares a court only where there are fewer than groups.
|
|
200
936
|
*
|
|
201
|
-
*
|
|
202
|
-
* the
|
|
203
|
-
*
|
|
937
|
+
* With T groups and N courts that last, T - N pairs have to share and
|
|
938
|
+
* the rest keep a court each. A group that is already on a court that
|
|
939
|
+
* lasts stays on it; one that is not has to move. A pair is a mover
|
|
940
|
+
* with a group that stays where it can, so the fewest groups change
|
|
941
|
+
* court, and a pair takes the court of the one that stays.
|
|
204
942
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* it, and the matches were simply lost: three of the four pooled
|
|
208
|
-
* competitions placed exactly half of theirs.
|
|
943
|
+
* @param {PlanningDay} day
|
|
944
|
+
* @param {Array} sharing
|
|
209
945
|
*/
|
|
210
|
-
|
|
211
|
-
const
|
|
946
|
+
shareBack(day, sharing) {
|
|
947
|
+
const turns = sharing.flatMap(entry => entry.turns.map(turn => ({ ...turn, entry })))
|
|
212
948
|
|
|
213
|
-
|
|
214
|
-
|
|
949
|
+
if (turns.length === 0) {
|
|
950
|
+
return
|
|
951
|
+
}
|
|
215
952
|
|
|
216
|
-
|
|
953
|
+
const first = turns[0].entry
|
|
954
|
+
const lasting = day.openCourts.filter(
|
|
955
|
+
court => first.back.every(slot => day.isFree(court, slot))
|
|
956
|
+
)
|
|
957
|
+
const lasts = new Set(lasting.map(court => court.id))
|
|
958
|
+
const needed = Math.max(0, turns.length - lasting.length)
|
|
959
|
+
|
|
960
|
+
const stayers = turns.filter(turn => lasts.has(turn.court.id))
|
|
961
|
+
const movers = turns.filter(turn => !lasts.has(turn.court.id))
|
|
962
|
+
const pairs = []
|
|
963
|
+
|
|
964
|
+
// A mover with a stayer of its own competition first, then across.
|
|
965
|
+
for (const entry of sharing) {
|
|
966
|
+
const own = turn => turn.entry === entry
|
|
967
|
+
|
|
968
|
+
for (const mover of movers.filter(own)) {
|
|
969
|
+
const stayer = stayers.find(
|
|
970
|
+
other => own(other) && !pairs.some(pair => pair.includes(other))
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
if (stayer && pairs.length < needed) {
|
|
974
|
+
pairs.push([stayer, mover])
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const free = list => list.filter(turn => !pairs.some(pair => pair.includes(turn)))
|
|
980
|
+
|
|
981
|
+
for (const mover of free(movers)) {
|
|
982
|
+
const stayer = free(stayers)[0]
|
|
983
|
+
|
|
984
|
+
if (stayer && pairs.length < needed) {
|
|
985
|
+
pairs.push([stayer, mover])
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Still short of pairs: whoever is left, two by two.
|
|
990
|
+
const rest = free(turns)
|
|
991
|
+
|
|
992
|
+
for (let i = 0; pairs.length < needed && i + 1 < rest.length; i += 2) {
|
|
993
|
+
pairs.push([rest[i], rest[i + 1]])
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const used = new Set()
|
|
997
|
+
const book = (members, court) => {
|
|
998
|
+
used.add(court.id)
|
|
999
|
+
|
|
1000
|
+
members.forEach(({ run, entry }, turn) => {
|
|
1001
|
+
// Alone, a group plays its back in a row; two take turns.
|
|
1002
|
+
const slots = members.length === 1
|
|
1003
|
+
? entry.back.slice(0, entry.shared.back)
|
|
1004
|
+
: entry.back.filter((slot, i) => i % 2 === turn)
|
|
1005
|
+
|
|
1006
|
+
if (court.id !== run.courts[0]) {
|
|
1007
|
+
run.movesAt ??= slots[0]?.key
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
this.placeMatches(day, entry.competition, run, court, slots, entry.shared.front)
|
|
1011
|
+
run.courts.push(court.id)
|
|
1012
|
+
})
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Groups that keep a court to themselves, on the one they are on.
|
|
1016
|
+
const singles = free(turns)
|
|
1017
|
+
|
|
1018
|
+
for (const turn of singles.filter(other => lasts.has(other.court.id))) {
|
|
1019
|
+
book([turn], turn.court)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
for (const members of pairs) {
|
|
1023
|
+
const own = members.find(turn => lasts.has(turn.court.id) && !used.has(turn.court.id))
|
|
1024
|
+
const court = own?.court ?? lasting.find(candidate => !used.has(candidate.id))
|
|
1025
|
+
|
|
1026
|
+
if (court) {
|
|
1027
|
+
book(members, court)
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
for (const turn of singles.filter(other => !lasts.has(other.court.id))) {
|
|
1032
|
+
const court = lasting.find(candidate => !used.has(candidate.id))
|
|
1033
|
+
|
|
1034
|
+
if (court) {
|
|
1035
|
+
book([turn], court)
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
for (const { competition, turns: own } of sharing) {
|
|
1040
|
+
for (const { run } of own) {
|
|
1041
|
+
if (new Set(run.courts).size > 1) {
|
|
1042
|
+
this.split.push({
|
|
1043
|
+
competition_id: competition.id,
|
|
1044
|
+
group_id: run.groupId,
|
|
1045
|
+
group_name: run.groupName ?? null,
|
|
1046
|
+
division_name: run.divisionName ?? null,
|
|
1047
|
+
date: day.date,
|
|
1048
|
+
courts: [...new Set(run.courts)],
|
|
1049
|
+
moves_at: run.movesAt ?? null,
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Books some of a run's matches on one court, in order.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {PlanningDay} day
|
|
1060
|
+
* @param {Object} competition
|
|
1061
|
+
* @param {Object} run
|
|
1062
|
+
* @param {Object} court
|
|
1063
|
+
* @param {Array} slots One slot per match, in play order.
|
|
1064
|
+
* @param {number} from Which match of the run the first slot holds.
|
|
1065
|
+
*/
|
|
1066
|
+
placeMatches(day, competition, run, court, slots, from) {
|
|
1067
|
+
slots.forEach((slot, i) => {
|
|
1068
|
+
day.place({
|
|
1069
|
+
competition_id: competition.id,
|
|
1070
|
+
division_id: run.divisionId ?? null,
|
|
1071
|
+
division_name: run.divisionName ?? null,
|
|
1072
|
+
division_gender: run.divisionGender ?? null,
|
|
1073
|
+
group_id: run.groupId ?? null,
|
|
1074
|
+
group_name: run.groupName ?? null,
|
|
1075
|
+
phase: 'group',
|
|
1076
|
+
round: 'group',
|
|
1077
|
+
match_slot: run.positions[from + i] ?? null,
|
|
1078
|
+
court_id: court.id,
|
|
1079
|
+
date: day.date,
|
|
1080
|
+
slot: slot.key,
|
|
1081
|
+
}, slot)
|
|
1082
|
+
|
|
1083
|
+
this.decrement(competition.id, 'group', day.date)
|
|
217
1084
|
})
|
|
218
1085
|
}
|
|
219
1086
|
|
|
220
|
-
|
|
1087
|
+
/**
|
|
1088
|
+
* Hands the day's courts to the competitions whose turn it is.
|
|
1089
|
+
*
|
|
1090
|
+
* @param {Array} entries Group slots that start together.
|
|
1091
|
+
* @param {boolean} requireLit Whether this is the lit pass.
|
|
1092
|
+
* @param {PlanningDay} day
|
|
1093
|
+
*/
|
|
1094
|
+
assign(entries, requireLit, day) {
|
|
1095
|
+
const { fiveTeam } = this
|
|
1096
|
+
const { date } = day
|
|
1097
|
+
|
|
1098
|
+
const eligible = entries.filter(({ competition, phase }) =>
|
|
1099
|
+
this.remainingFor(competition.id, phase, date) > 0
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
if (eligible.length === 0) {
|
|
1103
|
+
return
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const context = {
|
|
1107
|
+
date,
|
|
1108
|
+
taken: day.occupancy,
|
|
1109
|
+
blocks: day.plan.blocks,
|
|
1110
|
+
fiveTeam,
|
|
1111
|
+
requireLit,
|
|
1112
|
+
decrement: this.decrement.bind(this),
|
|
1113
|
+
remainingFor: this.remainingFor.bind(this),
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
assignCourts(
|
|
1117
|
+
day.proCourts,
|
|
1118
|
+
eligible.filter(({ competition }) => competition.is_pro),
|
|
1119
|
+
{ ...context, proCourt: true }
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
assignCourts(day.openCourts, eligible, context)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
buildFourTeamUnits() {
|
|
1126
|
+
const { fourTeamGroups } = this
|
|
1127
|
+
|
|
221
1128
|
const pools = new Map()
|
|
222
1129
|
|
|
223
1130
|
for (const entry of fourTeamGroups) {
|
|
224
|
-
const dates = groupDatesFor(entry.competition)
|
|
1131
|
+
const dates = this.groupDatesFor(entry.competition)
|
|
225
1132
|
const pairing = entry.group.pairing ?? 'auto'
|
|
226
1133
|
|
|
227
1134
|
const key = [
|
|
@@ -278,11 +1185,13 @@ export function autoFillPlanning(params) {
|
|
|
278
1185
|
* a vacant-seat bye, and there is no pairs/triples choice to honour
|
|
279
1186
|
* (see paired-court-planner.mjs's TODO).
|
|
280
1187
|
*/
|
|
281
|
-
|
|
1188
|
+
buildThreeTeamUnits() {
|
|
1189
|
+
const { threeTeamGroups } = this
|
|
1190
|
+
|
|
282
1191
|
const pools = new Map()
|
|
283
1192
|
|
|
284
1193
|
for (const entry of threeTeamGroups) {
|
|
285
|
-
const dates = groupDatesFor(entry.competition)
|
|
1194
|
+
const dates = this.groupDatesFor(entry.competition)
|
|
286
1195
|
|
|
287
1196
|
const isTeamCup = (entry.group.team_size ?? 2) >= 4
|
|
288
1197
|
|
|
@@ -318,167 +1227,139 @@ export function autoFillPlanning(params) {
|
|
|
318
1227
|
return units
|
|
319
1228
|
}
|
|
320
1229
|
|
|
321
|
-
|
|
1230
|
+
/*
|
|
1231
|
+
* The dates a competition is actually in its group phase.
|
|
1232
|
+
*
|
|
1233
|
+
* group_dates says which days the draw wants; the sessions say what
|
|
1234
|
+
* the competition is doing on them, and the two disagree whenever
|
|
1235
|
+
* the last group date is also the day it switches to knockout.
|
|
1236
|
+
*
|
|
1237
|
+
* A unit cuts its sequence into one part per date. Counting a
|
|
1238
|
+
* knockout day among them handed a half to a day that then refused
|
|
1239
|
+
* it, and the matches were simply lost: three of the four pooled
|
|
1240
|
+
* competitions placed exactly half of theirs.
|
|
1241
|
+
*/
|
|
1242
|
+
groupDatesFor(competition) {
|
|
1243
|
+
const { sortedSessions, darkFromMinutes, dayEndMinutes } = this.canvas
|
|
322
1244
|
|
|
323
|
-
|
|
1245
|
+
const sessions = sortedSessions.get(competition.id) ?? []
|
|
324
1246
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (group.is_pro === true && group.division_id != null) {
|
|
328
|
-
proDivisions.add(group.division_id)
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
1247
|
+
return (competition.group_dates ?? []).filter(date => {
|
|
1248
|
+
const own = slotsForDate(sessions, date, darkFromMinutes, dayEndMinutes)
|
|
332
1249
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
sortedSessions.set(
|
|
336
|
-
competition.id,
|
|
337
|
-
[...competition.sessions].sort((a, b) =>
|
|
338
|
-
(a.date + a.start_time).localeCompare(b.date + b.start_time)
|
|
339
|
-
)
|
|
340
|
-
)
|
|
1250
|
+
return own.length > 0 && own[0].phase === 'group'
|
|
1251
|
+
})
|
|
341
1252
|
}
|
|
342
1253
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
? [competition.games_needed_by_round]
|
|
362
|
-
: []))
|
|
363
|
-
|
|
364
|
-
for (const bracket of brackets) {
|
|
365
|
-
const waves = Array.isArray(bracket) ? bracket : bracket.waves
|
|
366
|
-
const division = Array.isArray(bracket) ? null : bracket
|
|
367
|
-
|
|
368
|
-
knockoutUnits.push(new KnockoutDraw({
|
|
369
|
-
competition,
|
|
370
|
-
waves,
|
|
371
|
-
divisionId: division?.division_id ?? null,
|
|
372
|
-
divisionName: division?.division_name ?? null,
|
|
373
|
-
divisionGender: division?.division_gender ?? null,
|
|
374
|
-
level: division?.level ?? 0,
|
|
375
|
-
isPro: proDivisions.has(division?.division_id),
|
|
376
|
-
}))
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (Array.isArray(competition.groups) && competition.groups.length === 0) {
|
|
380
|
-
continue
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const boltOn = pooledLeads(competition)
|
|
1254
|
+
/**
|
|
1255
|
+
* Whether this competition is mostly played on shared pairs.
|
|
1256
|
+
*
|
|
1257
|
+
* A competition of one shape is laid out by that shape's planner and
|
|
1258
|
+
* there is nothing to decide. A mixed one has to choose which goes
|
|
1259
|
+
* first, because the two do not divide into each other: a pair runs
|
|
1260
|
+
* in threes and a 5-team group in fives, so whichever is laid down
|
|
1261
|
+
* second has to fit around the other's leftovers.
|
|
1262
|
+
*
|
|
1263
|
+
* The shape with the most matches takes the courts first. Amateur
|
|
1264
|
+
* Doubles is mostly fives, so its handful of fours fill in around
|
|
1265
|
+
* them. Singles is the other way round, and planning its one 5-team
|
|
1266
|
+
* group first left a court with four slots nothing could use, since
|
|
1267
|
+
* nothing in the tournament is four slots long.
|
|
1268
|
+
*/
|
|
1269
|
+
pooledLeads(competition) {
|
|
1270
|
+
let pooled = 0
|
|
1271
|
+
let owned = 0
|
|
384
1272
|
|
|
385
1273
|
for (const group of competition.groups ?? []) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const asFull = alwaysByeShortGroups || !registrationClosed
|
|
389
|
-
const padded = asFull && size > group.team_ids.length
|
|
390
|
-
? paddedWithByes(group, size)
|
|
391
|
-
: group
|
|
392
|
-
|
|
393
|
-
fourTeamGroups.push({ competition, group: padded })
|
|
394
|
-
continue
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if ((group.team_ids ?? []).length === 3) {
|
|
398
|
-
threeTeamGroups.push({ competition, group, placed: new Set() })
|
|
399
|
-
continue
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (boltOn) {
|
|
403
|
-
boltOnGroups.push({ competition, group, placed: new Set() })
|
|
404
|
-
continue
|
|
405
|
-
}
|
|
1274
|
+
const teams = (group.team_ids ?? []).length
|
|
1275
|
+
const matches = (teams * (teams - 1)) / 2
|
|
406
1276
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
asFull: alwaysByeShortGroups || !registrationClosed,
|
|
412
|
-
})) {
|
|
413
|
-
const key = groupKey(competition.id, date)
|
|
414
|
-
remainingGroupByDate.set(
|
|
415
|
-
key, (remainingGroupByDate.get(key) ?? 0) + count
|
|
416
|
-
)
|
|
1277
|
+
if (this.shortGroup(group)) {
|
|
1278
|
+
pooled += matches
|
|
1279
|
+
} else {
|
|
1280
|
+
owned += matches
|
|
417
1281
|
}
|
|
418
1282
|
}
|
|
419
|
-
}
|
|
420
1283
|
|
|
421
|
-
|
|
422
|
-
return remainingGroupByDate.get(groupKey(competitionId, date)) ?? 0
|
|
1284
|
+
return pooled > owned
|
|
423
1285
|
}
|
|
424
1286
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1287
|
+
/**
|
|
1288
|
+
* A group with fewer teams than its division is configured for. It
|
|
1289
|
+
* plays three matches a day on a court pair shared with other short
|
|
1290
|
+
* groups, so it is held back from the ordinary allocation.
|
|
1291
|
+
*/
|
|
1292
|
+
/**
|
|
1293
|
+
* Whether a group is played on a shared pair of courts rather than
|
|
1294
|
+
* one of its own.
|
|
1295
|
+
*
|
|
1296
|
+
* Every 4-team group is: three rounds of two matches, a round taking
|
|
1297
|
+
* both courts, two or three groups taking turns. That is how a
|
|
1298
|
+
* 4-team group is played, not a special case for an uneven one.
|
|
1299
|
+
*
|
|
1300
|
+
* The exception is a group of four sitting in a division configured
|
|
1301
|
+
* for five. While registration is open it may still fill from the
|
|
1302
|
+
* waitlist, so capacity is held for it as a five - the spare cells
|
|
1303
|
+
* are the point, not waste. Once registration closes it is a 4-team
|
|
1304
|
+
* group like any other.
|
|
1305
|
+
*/
|
|
1306
|
+
shortGroup(group) {
|
|
1307
|
+
const { registrationClosed, alwaysByeShortGroups } = this.rules
|
|
431
1308
|
|
|
432
|
-
|
|
433
|
-
const proCourts = activeCourts.filter(court => court.pro)
|
|
434
|
-
const openCourts = activeCourts.filter(court => !court.pro)
|
|
1309
|
+
const teams = (group.team_ids ?? []).length
|
|
435
1310
|
|
|
436
|
-
|
|
1311
|
+
if (teams === 3) {
|
|
1312
|
+
return group.pool_size >= 4
|
|
1313
|
+
}
|
|
437
1314
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
overridesByDate.set(override.date, new Map())
|
|
1315
|
+
if (teams !== 4) {
|
|
1316
|
+
return false
|
|
441
1317
|
}
|
|
442
1318
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
override.available_from ? timeToMinutes(override.available_from) : null
|
|
446
|
-
)
|
|
1319
|
+
return group.pool_size === 4
|
|
1320
|
+
|| (registrationClosed && !alwaysByeShortGroups)
|
|
447
1321
|
}
|
|
448
1322
|
|
|
449
1323
|
/**
|
|
450
|
-
* The
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
1324
|
+
* The hours this competition may use on this date.
|
|
1325
|
+
*
|
|
1326
|
+
* Its sessions say: when it opens, how long a match is, and which
|
|
1327
|
+
* phase is on.
|
|
454
1328
|
*/
|
|
455
|
-
|
|
456
|
-
const
|
|
1329
|
+
dayFor(competition, on) {
|
|
1330
|
+
const {
|
|
1331
|
+
sortedSessions, darkFromMinutes, dayEndMinutes,
|
|
1332
|
+
} = this.canvas
|
|
1333
|
+
const own = sortedSessions.get(competition.id) ?? []
|
|
1334
|
+
const fromSessions = slotsForDate(
|
|
1335
|
+
own, on, darkFromMinutes, dayEndMinutes, this.anchorFor(on)
|
|
1336
|
+
)
|
|
457
1337
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
1338
|
+
return fromSessions
|
|
1339
|
+
}
|
|
461
1340
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}]))
|
|
1341
|
+
slotsFor(competitionId, date) {
|
|
1342
|
+
const {
|
|
1343
|
+
groupSlotsFromSessions, slots, sortedSessions, darkFromMinutes, dayEndMinutes,
|
|
1344
|
+
} = this.canvas
|
|
467
1345
|
|
|
468
|
-
if (
|
|
469
|
-
return
|
|
1346
|
+
if (!groupSlotsFromSessions && slots.length > 0) {
|
|
1347
|
+
return slots
|
|
470
1348
|
}
|
|
471
1349
|
|
|
472
|
-
return
|
|
473
|
-
.
|
|
474
|
-
|
|
1350
|
+
return slotsForDate(
|
|
1351
|
+
sortedSessions.get(Number(competitionId)) ?? [],
|
|
1352
|
+
date, darkFromMinutes, dayEndMinutes, this.anchorFor(date)
|
|
1353
|
+
)
|
|
475
1354
|
}
|
|
476
1355
|
|
|
477
1356
|
/**
|
|
478
1357
|
* The hour the day's first competition opens, which every
|
|
479
1358
|
* competition's slots are measured from.
|
|
480
1359
|
*/
|
|
481
|
-
|
|
1360
|
+
anchorFor(date) {
|
|
1361
|
+
const { competitions, sortedSessions, knockoutUnits } = this.canvas
|
|
1362
|
+
|
|
482
1363
|
const opens = []
|
|
483
1364
|
|
|
484
1365
|
for (const competition of competitions) {
|
|
@@ -506,391 +1387,165 @@ export function autoFillPlanning(params) {
|
|
|
506
1387
|
return opens.length === 0 ? null : Math.min(...opens)
|
|
507
1388
|
}
|
|
508
1389
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
)
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
fiveTeam.prepare({ slotsFor, courts: openCourts, usable: courtUsableAt })
|
|
521
|
-
|
|
522
|
-
for (const date of days) {
|
|
523
|
-
const taken = makeOccupancy()
|
|
524
|
-
|
|
525
|
-
const slotsByCompetition = new Map(
|
|
526
|
-
competitions.map(competition => [
|
|
527
|
-
competition.id,
|
|
528
|
-
competition.slots
|
|
529
|
-
? withMinutes(competition.slots)
|
|
530
|
-
: ((groupSlotsFromSessions || slots.length === 0)
|
|
531
|
-
? slotsForDate(
|
|
532
|
-
sortedSessions.get(competition.id) ?? [],
|
|
533
|
-
date, darkFromMinutes, dayEndMinutes, anchorFor(date)
|
|
534
|
-
)
|
|
535
|
-
: slots),
|
|
536
|
-
])
|
|
537
|
-
)
|
|
538
|
-
|
|
539
|
-
function assign(entries, requireLit) {
|
|
540
|
-
const eligible = entries.filter(({ competition, phase }) =>
|
|
541
|
-
remainingFor(competition.id, phase, date) > 0
|
|
542
|
-
)
|
|
543
|
-
|
|
544
|
-
if (eligible.length === 0) {
|
|
545
|
-
return
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
const context = {
|
|
549
|
-
date, taken, blocks, remainingFor, decrement, fiveTeam, requireLit,
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
assignCourts(
|
|
553
|
-
proCourts,
|
|
554
|
-
eligible.filter(({ competition }) => competition.is_pro),
|
|
555
|
-
{ ...context, proCourt: true }
|
|
556
|
-
)
|
|
557
|
-
|
|
558
|
-
assignCourts(openCourtsFor(date), eligible, context)
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
const timeline = []
|
|
562
|
-
|
|
563
|
-
for (const competition of competitions) {
|
|
564
|
-
const own = slotsByCompetition.get(competition.id) ?? []
|
|
565
|
-
|
|
566
|
-
const runLength = fiveTeam.runLengthFor(competition.id, date)
|
|
567
|
-
|
|
568
|
-
own.forEach((slot, slotIndex) => {
|
|
569
|
-
const phase = slot.phase
|
|
570
|
-
?? activePhase(sortedSessions.get(competition.id), date, slot.key)
|
|
571
|
-
|
|
572
|
-
if (phase !== 'group') {
|
|
573
|
-
return
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const last = own[Math.min(slotIndex + runLength - 1, own.length - 1)]
|
|
577
|
-
const needsLight = runNeedsLight(
|
|
578
|
-
own, slotIndex, runLength, darkFromMinutes
|
|
579
|
-
)
|
|
580
|
-
|
|
581
|
-
timeline.push({
|
|
582
|
-
competition, phase: 'group', slots: own, slotIndex, slot, needsLight,
|
|
583
|
-
})
|
|
584
|
-
})
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const lit = byStartTime(timeline.filter(e => e.needsLight), true)
|
|
588
|
-
|
|
589
|
-
for (const [, entries] of lit) {
|
|
590
|
-
assign(entries, true)
|
|
591
|
-
}
|
|
1390
|
+
/**
|
|
1391
|
+
* The non-pro courts usable on this date - openCourts, plus any
|
|
1392
|
+
* normally-pro court a director has opened up for the day. The
|
|
1393
|
+
* override travels with the court object itself (availableFromMinutes),
|
|
1394
|
+
* so courtUsableAt can enforce it without needing to know the date.
|
|
1395
|
+
*/
|
|
1396
|
+
openCourtsFor(date) {
|
|
1397
|
+
const { overridesByDate, openCourts, proCourts, activeCourts } = this.venue
|
|
592
1398
|
|
|
593
|
-
const
|
|
1399
|
+
const overrides = overridesByDate.get(date)
|
|
594
1400
|
|
|
595
|
-
|
|
596
|
-
|
|
1401
|
+
if (!overrides) {
|
|
1402
|
+
return openCourts
|
|
597
1403
|
}
|
|
598
1404
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
return false
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const unitSlots = slotsByCompetition
|
|
608
|
-
.get(unit.entries[0].competition.id) ?? slots
|
|
609
|
-
|
|
610
|
-
if (unitSlots.length === 0 || unitSlots[0].phase === 'knockout') {
|
|
611
|
-
return false
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
const sequence = unitLayoutFn(unit.entries.map(entry => ({
|
|
615
|
-
id: entry.group.id,
|
|
616
|
-
teamIds: entry.group.team_ids,
|
|
617
|
-
groupSize: entry.group.pool_size,
|
|
618
|
-
})))
|
|
619
|
-
|
|
620
|
-
const firstDay = Math.floor(sequence.length / unitDates.length)
|
|
621
|
-
const layout = dayIndex === 0
|
|
622
|
-
? sequence.slice(0, firstDay)
|
|
623
|
-
: sequence.slice(firstDay)
|
|
624
|
-
|
|
625
|
-
const before = blocks.length
|
|
626
|
-
|
|
627
|
-
const placed = placeLayout({
|
|
628
|
-
layout,
|
|
629
|
-
lookup: groupId => {
|
|
630
|
-
const entry = unit.entries.find(x => x.group.id === groupId)
|
|
631
|
-
|
|
632
|
-
return {
|
|
633
|
-
id: entry.group.id,
|
|
634
|
-
competitionId: entry.competition.id,
|
|
635
|
-
divisionId: entry.group.division_id ?? null,
|
|
636
|
-
divisionName: entry.group.division_name ?? null,
|
|
637
|
-
divisionGender: entry.group.division_gender ?? null,
|
|
638
|
-
groupName: entry.group.name ?? null,
|
|
639
|
-
}
|
|
640
|
-
},
|
|
641
|
-
courts: unit.isPro ? proCourts : openCourtsFor(date),
|
|
642
|
-
slots: unitSlots,
|
|
643
|
-
date,
|
|
644
|
-
occupancy: taken,
|
|
645
|
-
darkFrom: darkFromMinutes,
|
|
646
|
-
blocks,
|
|
647
|
-
})
|
|
648
|
-
|
|
649
|
-
if (placed) {
|
|
650
|
-
unit.placed.add(date)
|
|
651
|
-
return new Set(blocks.slice(before).map(block => block.court_id)).size
|
|
652
|
-
}
|
|
1405
|
+
const opened = new Map(proCourts
|
|
1406
|
+
.filter(court => overrides.has(court.id))
|
|
1407
|
+
.map(court => [court.id, {
|
|
1408
|
+
...court, availableFromMinutes: overrides.get(court.id),
|
|
1409
|
+
}]))
|
|
653
1410
|
|
|
654
|
-
|
|
1411
|
+
if (opened.size === 0) {
|
|
1412
|
+
return openCourts
|
|
655
1413
|
}
|
|
656
1414
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
&& unit.dates.includes(date)
|
|
662
|
-
)
|
|
663
|
-
|
|
664
|
-
if (due.length === 0) {
|
|
665
|
-
continue
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const order = []
|
|
669
|
-
|
|
670
|
-
for (const unit of due) {
|
|
671
|
-
if (!order.includes(unit.divisionId)) {
|
|
672
|
-
order.push(unit.divisionId)
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
const roomCourts = (kind ? proCourts : openCourtsFor(date)).length
|
|
677
|
-
|
|
678
|
-
const quota = new Map(order.map((divisionId, index) => {
|
|
679
|
-
const mine = due.filter(unit => unit.divisionId === divisionId)
|
|
680
|
-
const share = mine.length / due.length
|
|
681
|
-
|
|
682
|
-
return [
|
|
683
|
-
divisionId,
|
|
684
|
-
index === order.length - 1
|
|
685
|
-
? Infinity
|
|
686
|
-
: Math.max(2, Math.round(share * roomCourts)),
|
|
687
|
-
]
|
|
688
|
-
}))
|
|
689
|
-
|
|
690
|
-
const claimed = new Map()
|
|
691
|
-
|
|
692
|
-
for (const unit of due) {
|
|
693
|
-
const mine = claimed.get(unit.divisionId) ?? 0
|
|
694
|
-
|
|
695
|
-
if (mine >= (quota.get(unit.divisionId) ?? Infinity)) {
|
|
696
|
-
continue
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const courtsUsed = placeGroupUnit(unit, unitLayoutFn)
|
|
1415
|
+
return activeCourts
|
|
1416
|
+
.filter(court => !court.pro || opened.has(court.id))
|
|
1417
|
+
.map(court => opened.get(court.id) ?? court)
|
|
1418
|
+
}
|
|
700
1419
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1420
|
+
/**
|
|
1421
|
+
* What a draw still owes, given what is already planned for it.
|
|
1422
|
+
*
|
|
1423
|
+
* A day handed to the planner on its own gets the rounds that have
|
|
1424
|
+
* not been placed anywhere - a fill of the Thursday should not
|
|
1425
|
+
* lay out the R32 that was played on the Tuesday. A round part-laid
|
|
1426
|
+
* keeps the rest of itself; a round already whole is dropped.
|
|
1427
|
+
*
|
|
1428
|
+
* @param {Array} waves
|
|
1429
|
+
* @param {number} competitionId
|
|
1430
|
+
* @param {?number} divisionId
|
|
1431
|
+
* @returns {Array}
|
|
1432
|
+
*/
|
|
1433
|
+
owedWaves(waves, competitionId, divisionId) {
|
|
1434
|
+
const { alreadyPlaced } = this
|
|
707
1435
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
placeGroupUnit(unit, unitLayoutFn)
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
1436
|
+
if (alreadyPlaced.size === 0) {
|
|
1437
|
+
return waves
|
|
714
1438
|
}
|
|
715
1439
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (entry.placed.has(date) || !opened) {
|
|
726
|
-
continue
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
const own = slotsByCompetition.get(entry.competition.id) ?? []
|
|
730
|
-
|
|
731
|
-
if (own.length === 0 || own[0].phase === 'knockout') {
|
|
732
|
-
continue
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
const dates = groupDatesFor(entry.competition)
|
|
736
|
-
const teamIds = entry.group.team_ids ?? []
|
|
737
|
-
const size = entry.group.pool_size ?? teamIds.length
|
|
738
|
-
|
|
739
|
-
const asFull = alwaysByeShortGroups || !registrationClosed
|
|
740
|
-
const seats = asFull && size > teamIds.length
|
|
741
|
-
? Array.from({ length: size }, (_, i) => teamIds[i] ?? -(i + 1))
|
|
742
|
-
: teamIds
|
|
1440
|
+
return waves
|
|
1441
|
+
.map(wave => ({
|
|
1442
|
+
...wave,
|
|
1443
|
+
count: wave.count - (alreadyPlaced.get(
|
|
1444
|
+
[competitionId, divisionId, wave.round].join('|')
|
|
1445
|
+
) ?? 0),
|
|
1446
|
+
}))
|
|
1447
|
+
.filter(wave => wave.count > 0)
|
|
1448
|
+
}
|
|
743
1449
|
|
|
744
|
-
|
|
1450
|
+
/**
|
|
1451
|
+
* Whether this fixture of this group is already in the plan.
|
|
1452
|
+
*
|
|
1453
|
+
* @param {Map<number, Set<number>>} playedFixtures by group
|
|
1454
|
+
* @param {number} groupId
|
|
1455
|
+
* @param {number} index
|
|
1456
|
+
* @returns {boolean}
|
|
1457
|
+
*/
|
|
1458
|
+
fixtureDone(groupId, index) {
|
|
1459
|
+
return this.playedFixtures.get(groupId)?.has(index) === true
|
|
1460
|
+
}
|
|
745
1461
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
noTemplateWarned.add(entry.group.id)
|
|
1462
|
+
remainingFor(competitionId, phase, date) {
|
|
1463
|
+
const { remainingGroupByDate } = this
|
|
749
1464
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
kind: 'no-template',
|
|
753
|
-
division_id: entry.group.division_id ?? null,
|
|
754
|
-
division_name: entry.group.division_name ?? null,
|
|
755
|
-
group_id: entry.group.id,
|
|
756
|
-
group_name: entry.group.name ?? null,
|
|
757
|
-
teams: teamIds.length,
|
|
758
|
-
})
|
|
1465
|
+
return remainingGroupByDate.get(groupKey(competitionId, date)) ?? 0
|
|
1466
|
+
}
|
|
759
1467
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
GAMES_PER_POOL_SIZE[entry.group.pool_size] ?? 6
|
|
763
|
-
)
|
|
764
|
-
}
|
|
1468
|
+
decrement(competitionId, phase, date) {
|
|
1469
|
+
const { remainingGroupByDate } = this
|
|
765
1470
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
)
|
|
769
|
-
|
|
1471
|
+
const groupDateKey = groupKey(competitionId, date)
|
|
1472
|
+
remainingGroupByDate.set(
|
|
1473
|
+
groupDateKey, remainingGroupByDate.get(groupDateKey) - 1
|
|
1474
|
+
)
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
770
1477
|
|
|
771
|
-
if (cells.length === 0) {
|
|
772
|
-
continue
|
|
773
|
-
}
|
|
774
1478
|
|
|
775
|
-
const laneCourts = entry.group.is_pro === true
|
|
776
|
-
? proCourts
|
|
777
|
-
: openCourtsFor(date)
|
|
778
|
-
let landed = false
|
|
779
1479
|
|
|
780
|
-
|
|
1480
|
+
/**
|
|
1481
|
+
* How good a plan is, lowest winning: what it could not place, then
|
|
1482
|
+
* what it left overflowing its own knockout days, then how late it
|
|
1483
|
+
* runs.
|
|
1484
|
+
*
|
|
1485
|
+
* A day that fits is worth more than a day that finishes early, and a
|
|
1486
|
+
* plan that finishes early is worth more than one that dribbles into
|
|
1487
|
+
* the evening. Nothing else is measured, because nothing else is what
|
|
1488
|
+
* a director is deciding between at eleven at night.
|
|
1489
|
+
*
|
|
1490
|
+
* @param {Array} competitions
|
|
1491
|
+
* @param {Object} result
|
|
1492
|
+
* @returns {number[]}
|
|
1493
|
+
*/
|
|
1494
|
+
export function planScore(competitions, result) {
|
|
1495
|
+
const short = (result.shortfall ?? [])
|
|
1496
|
+
.reduce((total, entry) => total + (entry.missing ?? 0), 0)
|
|
781
1497
|
|
|
782
|
-
|
|
783
|
-
|
|
1498
|
+
const last = (result.blocks ?? [])
|
|
1499
|
+
.reduce((latest, block) => {
|
|
1500
|
+
const ends = `${block.date} ${block.slot}`
|
|
784
1501
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
courtUsableAt(court, slot)
|
|
788
|
-
&& taken.free(
|
|
789
|
-
date, court.id, slot.start_minutes, slot.end_minutes
|
|
790
|
-
)
|
|
791
|
-
)
|
|
1502
|
+
return ends > latest ? ends : latest
|
|
1503
|
+
}, '')
|
|
792
1504
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
}
|
|
1505
|
+
return [short, knockoutOverflow(competitions, result), last === '' ? 0 : 1, last]
|
|
1506
|
+
}
|
|
796
1507
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1508
|
+
/**
|
|
1509
|
+
* Plans the same canvas several ways and keeps the best of them.
|
|
1510
|
+
*
|
|
1511
|
+
* The levers a director reaches for on the night - open the pro
|
|
1512
|
+
* courts, bundle the groups denser, cut the rounds - are cheap to try
|
|
1513
|
+
* and hard to judge by eye, and the answer is not the same twice: it
|
|
1514
|
+
* depends on what is left to place and how much room is left to place
|
|
1515
|
+
* it in. So try them and measure, rather than choosing in advance.
|
|
1516
|
+
*
|
|
1517
|
+
* @param {Object} params What autoFillPlanning takes.
|
|
1518
|
+
* @param {Array<Object>} options One overlay of params per attempt.
|
|
1519
|
+
* @returns {Object} the winning result, with `chosen` saying which
|
|
1520
|
+
* overlay produced it.
|
|
1521
|
+
*/
|
|
1522
|
+
export function bestPlan(params, options = [{}]) {
|
|
1523
|
+
const competitions = params.competitions ?? []
|
|
812
1524
|
|
|
813
|
-
|
|
814
|
-
})
|
|
1525
|
+
let best = null
|
|
815
1526
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
}
|
|
1527
|
+
for (const option of options) {
|
|
1528
|
+
const result = autoFillPlanning({ ...params, ...option })
|
|
1529
|
+
const score = planScore(competitions, result)
|
|
821
1530
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
}
|
|
1531
|
+
if (best === null || cheaper(score, best.score)) {
|
|
1532
|
+
best = { result, score, option }
|
|
825
1533
|
}
|
|
826
|
-
|
|
827
|
-
const day = new PlanningDay({
|
|
828
|
-
date,
|
|
829
|
-
proCourts,
|
|
830
|
-
openCourts: openCourtsFor(date),
|
|
831
|
-
slotsByCompetition,
|
|
832
|
-
occupancy: taken,
|
|
833
|
-
plan,
|
|
834
|
-
darkFromMinutes,
|
|
835
|
-
lightBufferMinutes: lightBuffer,
|
|
836
|
-
})
|
|
837
|
-
|
|
838
|
-
assignKnockout(day, {
|
|
839
|
-
competitions,
|
|
840
|
-
draws: knockoutUnits,
|
|
841
|
-
sessions: sortedSessions,
|
|
842
|
-
split: splitRounds,
|
|
843
|
-
})
|
|
844
1534
|
}
|
|
845
1535
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
slotsFor, runLengthFor: (id, date) => fiveTeam.runLengthFor(id, date),
|
|
849
|
-
})
|
|
850
|
-
|
|
851
|
-
const placed = plan.blocks.filter(block => !plan.dropped.has(block))
|
|
852
|
-
|
|
853
|
-
for (const entry of threeTeamGroups) {
|
|
854
|
-
const got = placed.filter(block =>
|
|
855
|
-
block.group_id === entry.group.id && block.phase === 'group'
|
|
856
|
-
).length
|
|
857
|
-
const missing = GAMES_PER_POOL_SIZE[3] - got
|
|
858
|
-
|
|
859
|
-
if (missing > 0) {
|
|
860
|
-
plan.short(entry.competition, 'group', missing)
|
|
861
|
-
}
|
|
862
|
-
}
|
|
1536
|
+
return { ...best.result, chosen: best.option }
|
|
1537
|
+
}
|
|
863
1538
|
|
|
864
|
-
|
|
865
|
-
|
|
1539
|
+
function cheaper(score, than) {
|
|
1540
|
+
for (let i = 0; i < Math.max(score.length, than.length); i++) {
|
|
1541
|
+
if (score[i] === than[i]) {
|
|
866
1542
|
continue
|
|
867
1543
|
}
|
|
868
1544
|
|
|
869
|
-
|
|
870
|
-
.filter(([key]) =>
|
|
871
|
-
key.slice(0, key.indexOf(':')) === String(competition.id)
|
|
872
|
-
)
|
|
873
|
-
.reduce((total, [, left]) => total + Math.max(left, 0), 0)
|
|
874
|
-
|
|
875
|
-
const missingKnockout = knockoutUnits
|
|
876
|
-
.filter(draw => draw.competition.id === competition.id)
|
|
877
|
-
.reduce((total, draw) => total + draw.unplanned, 0)
|
|
878
|
-
|
|
879
|
-
plan.short(competition, 'group', missingGroup)
|
|
880
|
-
plan.short(competition, 'knockout', missingKnockout)
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
const result = plan.result(competitions)
|
|
884
|
-
|
|
885
|
-
const overflow = knockoutOverflow(competitions, result)
|
|
886
|
-
|
|
887
|
-
if (splitRounds || overflow === 0) {
|
|
888
|
-
return result
|
|
1545
|
+
return score[i] < than[i]
|
|
889
1546
|
}
|
|
890
1547
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
return knockoutOverflow(competitions, retried) < overflow ? retried : result
|
|
1548
|
+
return false
|
|
894
1549
|
}
|
|
895
1550
|
|
|
896
1551
|
/**
|
|
@@ -1099,6 +1754,7 @@ function groupKey(competitionId, date) {
|
|
|
1099
1754
|
*/
|
|
1100
1755
|
function pickRun({
|
|
1101
1756
|
planner, phase, competition, date, sectionIndex, court, usable, proCourt,
|
|
1757
|
+
strictQuota = false,
|
|
1102
1758
|
}) {
|
|
1103
1759
|
if (phase !== 'group') {
|
|
1104
1760
|
return null
|
|
@@ -1107,7 +1763,7 @@ function pickRun({
|
|
|
1107
1763
|
return planner.pick(competition.id, date, sectionIndex, {
|
|
1108
1764
|
courtPosition: usable.indexOf(court),
|
|
1109
1765
|
courtCount: usable.length,
|
|
1110
|
-
}, { proCourt })
|
|
1766
|
+
}, { proCourt, strictQuota })
|
|
1111
1767
|
}
|
|
1112
1768
|
|
|
1113
1769
|
function assignCourts(courtList, eligible, context) {
|
|
@@ -1228,30 +1884,53 @@ function withMinutes(slots) {
|
|
|
1228
1884
|
export function slotsForDate(
|
|
1229
1885
|
sessions, date, darkFromMinutes, dayEnd = 24 * 60, anchor = null
|
|
1230
1886
|
) {
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1887
|
+
// Every session the date has of its own, in order - a day can hold
|
|
1888
|
+
// more than one, and the phase already knew that: groups in the
|
|
1889
|
+
// morning and the knockout from three. Taking only the last of them
|
|
1890
|
+
// generated the whole day from three o'clock and left the morning
|
|
1891
|
+
// with no slots to play in at all.
|
|
1892
|
+
//
|
|
1893
|
+
// A date with no session of its own carries on under the last one
|
|
1894
|
+
// before it, which is how a competition keeps its clock across the
|
|
1895
|
+
// days between its sessions.
|
|
1896
|
+
const own = (sessions ?? [])
|
|
1897
|
+
.filter(session => session.date === date)
|
|
1898
|
+
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
|
1899
|
+
|
|
1900
|
+
const running = own.length > 0
|
|
1901
|
+
? own
|
|
1902
|
+
: [activeSession(sessions, date)].filter(Boolean)
|
|
1903
|
+
|
|
1904
|
+
if (running.length === 0) {
|
|
1234
1905
|
return []
|
|
1235
1906
|
}
|
|
1236
1907
|
|
|
1237
|
-
const length = Math.max(1, active.playtime ?? MATCH_MINUTES)
|
|
1238
|
-
const opens = timeToMinutes(active.start_time)
|
|
1239
|
-
|
|
1240
|
-
const from = anchor == null || anchor > opens
|
|
1241
|
-
? opens
|
|
1242
|
-
: anchor + Math.ceil((opens - anchor) / length) * length
|
|
1243
|
-
|
|
1244
1908
|
const slots = []
|
|
1245
1909
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1910
|
+
running.forEach((session, index) => {
|
|
1911
|
+
const length = Math.max(1, session.playtime ?? MATCH_MINUTES)
|
|
1912
|
+
const opens = timeToMinutes(session.start_time)
|
|
1913
|
+
|
|
1914
|
+
// A session runs until the next one takes over, and the last one
|
|
1915
|
+
// until the day ends.
|
|
1916
|
+
const until = index + 1 < running.length
|
|
1917
|
+
? timeToMinutes(running[index + 1].start_time)
|
|
1918
|
+
: dayEnd
|
|
1919
|
+
|
|
1920
|
+
const from = index > 0 || anchor == null || anchor > opens
|
|
1921
|
+
? opens
|
|
1922
|
+
: anchor + Math.ceil((opens - anchor) / length) * length
|
|
1923
|
+
|
|
1924
|
+
for (let start = from; start + length <= until; start += length) {
|
|
1925
|
+
slots.push({
|
|
1926
|
+
key: minutesToTime(start),
|
|
1927
|
+
start_minutes: start,
|
|
1928
|
+
end_minutes: start + length,
|
|
1929
|
+
lit_required: darkFromMinutes != null && start + length > darkFromMinutes,
|
|
1930
|
+
phase: session.phase,
|
|
1931
|
+
})
|
|
1932
|
+
}
|
|
1933
|
+
})
|
|
1255
1934
|
|
|
1256
1935
|
return slots
|
|
1257
1936
|
}
|