@opndev/tennis-match-center 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 }