@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.
@@ -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, placeLayout,
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
- courts,
91
- slots: rawSlots,
92
- days,
93
- competitions: allCompetitions,
94
- registrationClosed = false,
95
- alwaysByeShortGroups = false,
96
- courtOverrides = [],
97
- darkFrom = null,
98
- dayEnd = '24:00',
99
- lightBuffer = undefined,
100
- groupSlotsFromSessions = false,
101
- splitRounds = false,
102
- } = params
103
-
104
- const competitions = allCompetitions
105
- .filter(competition => competition.available_for_planning !== false)
106
- .map(competition => ({
107
- ...competition,
108
- groups: (competition.groups ?? []).map(group => ({
109
- ...group,
110
- team_ids: group.team_ids
111
- ?? Array.from({ length: group.team_count ?? 0 }, (_, i) => i),
112
- })),
113
- }))
114
-
115
- const slots = withMinutes(rawSlots ?? [])
116
- const darkFromMinutes = darkFrom == null ? null : timeToMinutes(darkFrom)
117
-
118
- const dayEndMinutes = timeToMinutes(dayEnd)
119
- const plan = new Plan()
120
- const blocks = plan.blocks
121
- const remainingGroupByDate = new Map()
122
- const fiveTeam = makeFiveTeamPlanner()
123
- const fourTeamGroups = []
124
- const threeTeamGroups = []
125
- const boltOnGroups = []
126
- const noTemplateWarned = new Set()
127
- let fourTeamUnits = null
128
- let builtThreeTeamUnits = null
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
- * Whether this competition is mostly played on shared pairs.
133
- *
134
- * A competition of one shape is laid out by that shape's planner and
135
- * there is nothing to decide. A mixed one has to choose which goes
136
- * first, because the two do not divide into each other: a pair runs
137
- * in threes and a 5-team group in fives, so whichever is laid down
138
- * second has to fit around the other's leftovers.
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
- * The shape with the most matches takes the courts first. Amateur
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
- function pooledLeads(competition) {
147
- let pooled = 0
148
- let owned = 0
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
- for (const group of competition.groups ?? []) {
151
- const teams = (group.team_ids ?? []).length
152
- const matches = (teams * (teams - 1)) / 2
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
- if (shortGroup(group)) {
155
- pooled += matches
156
- } else {
157
- owned += matches
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
- return pooled > owned
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
- * A group with fewer teams than its division is configured for. It
166
- * plays three matches a day on a court pair shared with other short
167
- * groups, so it is held back from the ordinary allocation.
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
- * Whether a group is played on a shared pair of courts rather than
171
- * one of its own.
847
+ * Plays a section on shared courts, for the competitions that have one.
172
848
  *
173
- * Every 4-team group is: three rounds of two matches, a round taking
174
- * both courts, two or three groups taking turns. That is how a
175
- * 4-team group is played, not a special case for an uneven one.
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
- * The exception is a group of four sitting in a division configured
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
- function shortGroup(group) {
184
- const teams = (group.team_ids ?? []).length
861
+ assignShared(day) {
862
+ const { fiveTeam, competitions } = this
863
+ const { date } = day
185
864
 
186
- if (teams === 3) {
187
- return group.pool_size >= 4
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
- if (teams !== 4) {
191
- return false
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
- return group.pool_size === 4
195
- || (registrationClosed && !alwaysByeShortGroups)
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
- * The dates a competition is actually in its group phase.
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
- * group_dates says which days the draw wants; the sessions say what
202
- * the competition is doing on them, and the two disagree whenever
203
- * the last group date is also the day it switches to knockout.
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
- * A unit cuts its sequence into one part per date. Counting a
206
- * knockout day among them handed a half to a day that then refused
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
- function groupDatesFor(competition) {
211
- const sessions = sortedSessions.get(competition.id) ?? []
946
+ shareBack(day, sharing) {
947
+ const turns = sharing.flatMap(entry => entry.turns.map(turn => ({ ...turn, entry })))
212
948
 
213
- return (competition.group_dates ?? []).filter(date => {
214
- const own = slotsForDate(sessions, date, darkFromMinutes, dayEndMinutes)
949
+ if (turns.length === 0) {
950
+ return
951
+ }
215
952
 
216
- return own.length > 0 && own[0].phase === 'group'
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
- function buildFourTeamUnits() {
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
- function buildThreeTeamUnits() {
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
- const knockoutUnits = []
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
- const proDivisions = new Set()
1245
+ const sessions = sortedSessions.get(competition.id) ?? []
324
1246
 
325
- for (const competition of competitions) {
326
- for (const group of competition.groups ?? []) {
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
- const sortedSessions = new Map()
334
- for (const competition of competitions) {
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
- for (const competition of competitions) {
344
- const derived = (competition.divisions ?? [])
345
- .filter(division =>
346
- division.knockout_matches != null || division.team_count != null
347
- )
348
- .map(division => ({
349
- division_id: division.id,
350
- division_name: division.name,
351
- division_gender: division.gender ?? null,
352
- level: division.level ?? 0,
353
- waves: wavesForDivision(division, { itf: competition.itf === true }),
354
- }))
355
- .filter(bracket => bracket.waves.length > 0)
356
-
357
- const brackets = derived.length > 0
358
- ? derived
359
- : (competition.knockout_rounds_by_division
360
- ?? (competition.games_needed_by_round?.length
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
- if (shortGroup(group)) {
387
- const size = group.pool_size ?? group.team_ids.length
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
- for (const { date, count } of fiveTeam.enqueue({
408
- competition,
409
- group,
410
- dates: groupDatesFor(competition),
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
- function remainingFor(competitionId, phase, date) {
422
- return remainingGroupByDate.get(groupKey(competitionId, date)) ?? 0
1284
+ return pooled > owned
423
1285
  }
424
1286
 
425
- function decrement(competitionId, phase, date) {
426
- const groupDateKey = groupKey(competitionId, date)
427
- remainingGroupByDate.set(
428
- groupDateKey, remainingGroupByDate.get(groupDateKey) - 1
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
- const activeCourts = courts.filter(court => court.active)
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
- const overridesByDate = new Map()
1311
+ if (teams === 3) {
1312
+ return group.pool_size >= 4
1313
+ }
437
1314
 
438
- for (const override of courtOverrides) {
439
- if (!overridesByDate.has(override.date)) {
440
- overridesByDate.set(override.date, new Map())
1315
+ if (teams !== 4) {
1316
+ return false
441
1317
  }
442
1318
 
443
- overridesByDate.get(override.date).set(
444
- override.court_id,
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 non-pro courts usable on this date - openCourts, plus any
451
- * normally-pro court a director has opened up for the day. The
452
- * override travels with the court object itself (availableFromMinutes),
453
- * so courtUsableAt can enforce it without needing to know the date.
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
- function openCourtsFor(date) {
456
- const overrides = overridesByDate.get(date)
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
- if (!overrides) {
459
- return openCourts
460
- }
1338
+ return fromSessions
1339
+ }
461
1340
 
462
- const opened = new Map(proCourts
463
- .filter(court => overrides.has(court.id))
464
- .map(court => [court.id, {
465
- ...court, availableFromMinutes: overrides.get(court.id),
466
- }]))
1341
+ slotsFor(competitionId, date) {
1342
+ const {
1343
+ groupSlotsFromSessions, slots, sortedSessions, darkFromMinutes, dayEndMinutes,
1344
+ } = this.canvas
467
1345
 
468
- if (opened.size === 0) {
469
- return openCourts
1346
+ if (!groupSlotsFromSessions && slots.length > 0) {
1347
+ return slots
470
1348
  }
471
1349
 
472
- return activeCourts
473
- .filter(court => !court.pro || opened.has(court.id))
474
- .map(court => opened.get(court.id) ?? court)
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
- function anchorFor(date) {
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
- function slotsFor(competitionId, date) {
510
- if (!groupSlotsFromSessions && slots.length > 0) {
511
- return slots
512
- }
513
-
514
- return slotsForDate(
515
- sortedSessions.get(Number(competitionId)) ?? [],
516
- date, darkFromMinutes, dayEndMinutes, anchorFor(date)
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 daylight = byStartTime(timeline.filter(e => !e.needsLight), false)
1399
+ const overrides = overridesByDate.get(date)
594
1400
 
595
- for (const [, entries] of daylight) {
596
- assign(entries, false)
1401
+ if (!overrides) {
1402
+ return openCourts
597
1403
  }
598
1404
 
599
- function placeGroupUnit(unit, unitLayoutFn) {
600
- const unitDates = unit.dates.slice(0, 2)
601
- const dayIndex = unitDates.indexOf(date)
602
-
603
- if (dayIndex === -1 || unit.placed.has(date)) {
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
- return false
1411
+ if (opened.size === 0) {
1412
+ return openCourts
655
1413
  }
656
1414
 
657
- function placeGroupUnitsForDate(units, unitLayoutFn) {
658
- for (const kind of [true, false]) {
659
- const due = units.filter(unit =>
660
- unit.isPro === kind && !unit.placed.has(date)
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
- if (courtsUsed) {
702
- claimed.set(
703
- unit.divisionId, (claimed.get(unit.divisionId) ?? 0) + courtsUsed
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
- for (const unit of due) {
709
- if (!unit.placed.has(date)) {
710
- placeGroupUnit(unit, unitLayoutFn)
711
- }
712
- }
713
- }
1436
+ if (alreadyPlaced.size === 0) {
1437
+ return waves
714
1438
  }
715
1439
 
716
- fourTeamUnits ??= buildFourTeamUnits()
717
- placeGroupUnitsForDate(fourTeamUnits, fourTeamUnitLayout)
718
-
719
- builtThreeTeamUnits ??= buildThreeTeamUnits()
720
- placeGroupUnitsForDate(builtThreeTeamUnits, threeTeamUnitLayout)
721
-
722
- for (const entry of boltOnGroups) {
723
- const opened = groupDatesFor(entry.competition).includes(date)
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
- const runs = groupRuns({ teamIds: seats, groupSize: size })
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
- if (runs.length === 0 && teamIds.length > 0
747
- && !noTemplateWarned.has(entry.group.id)) {
748
- noTemplateWarned.add(entry.group.id)
1462
+ remainingFor(competitionId, phase, date) {
1463
+ const { remainingGroupByDate } = this
749
1464
 
750
- plan.warn({
751
- competition_id: entry.competition.id,
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
- plan.short(
761
- entry.competition, 'group',
762
- GAMES_PER_POOL_SIZE[entry.group.pool_size] ?? 6
763
- )
764
- }
1468
+ decrement(competitionId, phase, date) {
1469
+ const { remainingGroupByDate } = this
765
1470
 
766
- const today = runs.filter(run =>
767
- (dates.length === 1 ? dates[0] : dates[run.day - 1]) === date
768
- )
769
- const cells = today.flatMap(run => run.positions)
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
- const last = own.length - cells.length
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
- for (let start = 0; start <= last && !landed; start++) {
783
- const window = own.slice(start, start + cells.length)
1498
+ const last = (result.blocks ?? [])
1499
+ .reduce((latest, block) => {
1500
+ const ends = `${block.date} ${block.slot}`
784
1501
 
785
- for (const court of laneCourts) {
786
- const usable = window.every(slot =>
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
- if (!usable) {
794
- continue
795
- }
1505
+ return [short, knockoutOverflow(competitions, result), last === '' ? 0 : 1, last]
1506
+ }
796
1507
 
797
- window.forEach((slot, offset) => {
798
- blocks.push({
799
- competition_id: entry.competition.id,
800
- division_id: entry.group.division_id ?? null,
801
- division_name: entry.group.division_name ?? null,
802
- division_gender: entry.group.division_gender ?? null,
803
- group_id: entry.group.id,
804
- group_name: entry.group.name ?? null,
805
- phase: 'group',
806
- round: 'group',
807
- match_slot: cells[offset] ?? null,
808
- court_id: court.id,
809
- date,
810
- slot: slot.key,
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
- taken.take(date, court.id, slot.start_minutes, slot.end_minutes)
814
- })
1525
+ let best = null
815
1526
 
816
- entry.placed.add(date)
817
- landed = true
818
- break
819
- }
820
- }
1527
+ for (const option of options) {
1528
+ const result = autoFillPlanning({ ...params, ...option })
1529
+ const score = planScore(competitions, result)
821
1530
 
822
- if (!landed) {
823
- plan.short(entry.competition, 'group', cells.length)
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
- refuseLateStarts({
847
- competitions, sortedSessions, plan, overridesByDate,
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
- for (const competition of competitions) {
865
- if (competition.sessions.length === 0) {
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
- const missingGroup = [...remainingGroupByDate]
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
- const retried = autoFillPlanning({ ...params, splitRounds: true })
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
- const active = activeSession(sessions, date)
1232
-
1233
- if (!active) {
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
- for (let start = from; start + length <= dayEnd; start += length) {
1247
- slots.push({
1248
- key: minutesToTime(start),
1249
- start_minutes: start,
1250
- end_minutes: start + length,
1251
- lit_required: darkFromMinutes != null && start + length > darkFromMinutes,
1252
- phase: active.phase,
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
  }