@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,6 +5,37 @@
5
5
  import { HTMLElementSugar } from '@skirbi/sugar'
6
6
  import { seedGroups, groupName } from '../roster-seeding.mjs'
7
7
 
8
+ const DAY = new Intl.DateTimeFormat('en-GB', {
9
+ weekday: 'short',
10
+ day: 'numeric',
11
+ month: 'short',
12
+ timeZone: 'UTC',
13
+ })
14
+
15
+ /**
16
+ * The day an ISO timestamp falls on, read off the string so the event's
17
+ * own clock is what is said, not the browser's.
18
+ *
19
+ * @param {string} iso
20
+ * @returns {string}
21
+ */
22
+ function dayOf(iso) {
23
+ return DAY.format(new Date(`${iso.slice(0, 10)}T12:00:00Z`))
24
+ }
25
+
26
+ /**
27
+ * When a group starts playing, as far as the planning knows it: the
28
+ * first match of each day it plays.
29
+ *
30
+ * @param {Array<string>} starts - ISO timestamps, one per day
31
+ * @returns {string}
32
+ */
33
+ export function startsLine(starts) {
34
+ return starts
35
+ .map(iso => `${dayOf(iso)}, ${iso.slice(11, 16)}`)
36
+ .join(' / ')
37
+ }
38
+
8
39
  /**
9
40
  * <tennis-roster-board api="..." save-api="...">
10
41
  *
@@ -173,11 +204,11 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
173
204
  .map(id => this.saved.teams.find(team => team.id === id))
174
205
  .filter(Boolean)
175
206
 
176
- columns.appendChild(this.columnFor(group.name, teams))
207
+ columns.appendChild(this.columnFor(group.name, teams, group.starts))
177
208
  }
178
209
  }
179
210
 
180
- columnFor(currentGroupName, teams) {
211
+ columnFor(currentGroupName, teams, starts = []) {
181
212
  const column = document.createElement('div')
182
213
 
183
214
  column.dataset.group = currentGroupName ?? ''
@@ -212,6 +243,15 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
212
243
  count.textContent = String(teams.length)
213
244
  heading.appendChild(count)
214
245
 
246
+ if (starts?.length) {
247
+ const when = document.createElement('p')
248
+
249
+ when.dataset.planned = ''
250
+ when.title = 'First match of the group, per day'
251
+ when.textContent = startsLine(starts)
252
+ column.appendChild(when)
253
+ }
254
+
215
255
  const list = document.createElement('div')
216
256
  list.dataset.list = ''
217
257
  column.appendChild(list)
@@ -10,7 +10,7 @@ import { competitionHue, divisionHues } from '../division-hues.mjs'
10
10
  import { setNameText } from '../flags.mjs'
11
11
  import { rowsForDate } from '../day-rows.mjs'
12
12
  import { courtCanTake, toMinutes } from '../lighting.mjs'
13
- import { PX_PER_MINUTE, TennisDayGridElement } from './day-grid.mjs'
13
+ import { TennisDayGridElement } from './day-grid.mjs'
14
14
  import { withBusyButton } from '../busy-button.mjs'
15
15
 
16
16
  const KNOCKOUT_ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
@@ -468,10 +468,15 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
468
468
  * Sent as a delete rather than as an empty save: which rows to drop
469
469
  * follows from the event and the date, and a board that holds one
470
470
  * day at a time could not send the rest of them to be replaced.
471
+ *
472
+ * A played match is not dropped. The database refuses to let the
473
+ * planner delete or move one, so the server keeps it - and a board
474
+ * that emptied its own copy anyway would show a morning that has
475
+ * been played as an empty morning until the next reload.
471
476
  */
472
477
  clearAll() {
473
478
  return this.clear(this.getConfig()['clear-api'], null, () => {
474
- this.matches = []
479
+ this.matches = this.matches.filter(match => match.played === true)
475
480
  this.unscheduledByCompetition = new Map()
476
481
  })
477
482
  }
@@ -482,7 +487,9 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
482
487
  }
483
488
 
484
489
  return this.clear(`${this.getConfig()['day-api']}/${date}`, date, () => {
485
- this.matches = this.matches.filter(match => match.date !== date)
490
+ this.matches = this.matches.filter(match =>
491
+ match.date !== date || match.played === true
492
+ )
486
493
  })
487
494
  }
488
495
 
@@ -948,9 +955,6 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
948
955
  rows: this.rowsFor(date),
949
956
  cellFor: (court, courtIndex, slot) => this.cellFor(court, date, slot),
950
957
  compact: this.$('[data-narrow-empty]')?.checked ?? false,
951
- // A scheduled cell carries four names and what is being played,
952
- // where a planned one carries a division and a group.
953
- scale: PX_PER_MINUTE * 2,
954
958
  })
955
959
 
956
960
  return grid
@@ -20,13 +20,18 @@
20
20
  * division needs across every section, keyed by division id.
21
21
  * @param {number[]} params.capacities How many courts each section can
22
22
  * actually host (already filtered for lit/usable, per caller).
23
+ * @param {number} [params.rotation] Which day of the competition this
24
+ * is, counting from zero. Where a division's runs do not divide
25
+ * evenly over the sections, the extra ones move two sections on each
26
+ * day, the same way its groups do.
23
27
  * @returns {{
24
28
  * quotasBySection: Array<Map<string|number, number>>,
25
29
  * bandsBySection: Array<Map<string|number, {start: number, end: number}>>
26
30
  * }}
27
31
  */
28
- export function computeSectionBands({ divisions, totals, capacities }) {
32
+ export function computeSectionBands({ divisions, totals, capacities, rotation = 0 }) {
29
33
  const sectionCount = capacities.length
34
+ const evened = evenQuotas({ divisions, totals, capacities, rotation })
30
35
  const quotasBySection = []
31
36
  const bandsBySection = []
32
37
  const granted = new Map(divisions.map(id => [id, 0]))
@@ -104,6 +109,15 @@ export function computeSectionBands({ divisions, totals, capacities }) {
104
109
  room -= 1
105
110
  }
106
111
 
112
+ // Where every division can be spread as evenly as its runs allow
113
+ // and the sections can hold it, that is the answer, whatever the
114
+ // capacity-driven shares above came to.
115
+ if (evened) {
116
+ for (const id of divisions) {
117
+ quotas.set(id, evened[section].get(id))
118
+ }
119
+ }
120
+
107
121
  // The band a division sits in, as a fraction of the section's own
108
122
  // courts, straight from what it was just given. Divisions come in
109
123
  // priority order, so the strongest field takes the lowest courts
@@ -129,6 +143,71 @@ export function computeSectionBands({ divisions, totals, capacities }) {
129
143
  return { quotasBySection, bandsBySection }
130
144
  }
131
145
 
146
+ /**
147
+ * Each division's runs spread as evenly over the sections as they go.
148
+ *
149
+ * Every division takes the whole number of runs that divides evenly in
150
+ * each section, and whatever is left over goes one to a section, to the
151
+ * sections with the most room, so the extra run of one division sits
152
+ * where another's does not and the sections fill alike. Ties go to the
153
+ * earliest section, turned two sections on for each day of rotation:
154
+ * the group that had an extra run in a section yesterday is the one a
155
+ * rotation sends to the section two on today.
156
+ *
157
+ * Only when the sections can hold it. If they cannot, the caller's own
158
+ * capacity-driven shares are all there is, and this says so with null.
159
+ *
160
+ * @returns {?Array<Map<string|number, number>>} quotas by section
161
+ */
162
+ function evenQuotas({ divisions, totals, capacities, rotation }) {
163
+ const sections = capacities.length
164
+
165
+ if (sections === 0) {
166
+ return null
167
+ }
168
+
169
+ const load = new Array(sections).fill(0)
170
+ const quotas = capacities.map(() => new Map())
171
+
172
+ for (const id of divisions) {
173
+ const base = Math.floor((totals.get(id) ?? 0) / sections)
174
+
175
+ for (let section = 0; section < sections; section++) {
176
+ quotas[section].set(id, base)
177
+ load[section] += base
178
+ }
179
+ }
180
+
181
+ // Earliest first, turned by the day: [0, 1, 2], then [2, 0, 1].
182
+ const turn = (2 * rotation) % sections
183
+ const order = Array.from({ length: sections }, (_, i) => (i + turn) % sections)
184
+
185
+ for (const id of divisions) {
186
+ const extra = (totals.get(id) ?? 0) % sections
187
+ const given = new Set()
188
+
189
+ for (let i = 0; i < extra; i++) {
190
+ let best = -1
191
+
192
+ for (const section of order) {
193
+ if (given.has(section)) {
194
+ continue
195
+ }
196
+
197
+ if (best === -1 || capacities[section] - load[section] > capacities[best] - load[best]) {
198
+ best = section
199
+ }
200
+ }
201
+
202
+ given.add(best)
203
+ load[best] += 1
204
+ quotas[best].set(id, quotas[best].get(id) + 1)
205
+ }
206
+ }
207
+
208
+ return load.every((runs, section) => runs <= capacities[section]) ? quotas : null
209
+ }
210
+
132
211
  /**
133
212
  * How far a court (as a fraction of its list) sits from a division's
134
213
  * band. Zero inside the band; a band is an interval, not a point, so a
@@ -211,6 +211,6 @@ export function fourTeamUnitSizes(count, { dense = false } = {}) {
211
211
  * @param {Array<{id: number, teamIds: number[]}>} groups
212
212
  * @returns {Array<Array<{groupId: number, index: number}>>} slot by court
213
213
  */
214
- export function fourTeamUnitLayout(groups) {
215
- return unitLayout(groups, { template: FOUR_TEAM_TEMPLATE, matchesFor: fourTeamMatches })
214
+ export function fourTeamUnitLayout(groups, done = null) {
215
+ return unitLayout(groups, { template: FOUR_TEAM_TEMPLATE, matchesFor: fourTeamMatches, done })
216
216
  }
package/lib/index.mjs CHANGED
@@ -2,6 +2,6 @@
2
2
  //
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
- const VERSION = "0.0.2";export { VERSION }
5
+ const VERSION = "0.0.4";export { VERSION }
6
6
  export { autoFillBracket } from './bracket-autofill.mjs'
7
7
 
@@ -58,6 +58,7 @@ export class KnockoutDraw {
58
58
  this.lastCourts = null
59
59
  this.lastParts = 1
60
60
  this.freeFrom = -Infinity
61
+ this.freeDate = null
61
62
  }
62
63
 
63
64
  /** Whether every round of the draw has been planned. */
@@ -108,11 +109,22 @@ export class KnockoutDraw {
108
109
  * Whether this draw may go on in this slot: it has a round left and
109
110
  * has had whatever rest it is owed.
110
111
  *
112
+ * Rest is owed within a day and a night is more of it than any rule
113
+ * asks, so a draw arriving on a new date is ready from its first
114
+ * slot. The slot index it was last free from means nothing on
115
+ * another day's list, which is what used to lock a draw that could
116
+ * not finish out of every day after it.
117
+ *
111
118
  * @param {number} slotIndex
119
+ * @param {?string} [date]
112
120
  * @returns {boolean}
113
121
  */
114
- readyAt(slotIndex) {
115
- return !this.done && slotIndex >= this.freeFrom
122
+ readyAt(slotIndex, date = null) {
123
+ if (this.done) {
124
+ return false
125
+ }
126
+
127
+ return date !== this.freeDate || slotIndex >= this.freeFrom
116
128
  }
117
129
 
118
130
  /**
@@ -123,19 +135,31 @@ export class KnockoutDraw {
123
135
  * narrow it leaves the parts; quarters and eighths need parts that
124
136
  * still hold matches of their own.
125
137
  *
138
+ * A round does not have to be a power of two. Three of a round of
139
+ * eight played and five left is the same thing as a bracket with
140
+ * byes, and five still halves - 3 then 2 - which is what pays for
141
+ * the rest. A cut that would leave a part with nothing in it is not
142
+ * offered.
143
+ *
126
144
  * @param {boolean} split Whether cutting is allowed at all.
127
145
  * @returns {Array<{parts: number, count: number}>} Courts needed
128
- * (count) for each number of parts.
146
+ * (count) for the widest part.
129
147
  */
130
148
  cuts(split) {
131
149
  const options = []
132
150
 
133
151
  for (const parts of SPLIT_SIZES) {
134
- if (parts > 1 && (!split || this.width % parts !== 0)) {
152
+ if (parts > 1 && !split) {
135
153
  continue
136
154
  }
137
155
 
138
- const count = parts === 1 ? this.width : this.width / parts
156
+ const count = parts === 1 ? this.width : Math.ceil(this.width / parts)
157
+
158
+ // Every part has to hold something: a round of five in four
159
+ // parts is three parts and an empty one.
160
+ if (parts > 1 && count * (parts - 1) >= this.width) {
161
+ continue
162
+ }
139
163
 
140
164
  if (parts > 2 && count < MIN_ROUND_PART) {
141
165
  continue
@@ -190,9 +214,11 @@ export class KnockoutDraw {
190
214
  * @param {number} params.parts The cut they were booked in.
191
215
  * @param {number} params.slotIndex Where the cut started.
192
216
  * @param {number} params.restSlots Slots owed after a whole round.
217
+ * @param {?string} [params.date] The day that slot index belongs to.
193
218
  */
194
- book({ count, parts, slotIndex, restSlots }) {
219
+ book({ count, parts, slotIndex, restSlots, date = null }) {
195
220
  this.placed += count
221
+ this.freeDate = date
196
222
 
197
223
  if (this.placedInRound + count < this.width) {
198
224
  this.placedInRound += count
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { MATCH_MINUTES, REST_MINUTES } from './match-time.mjs'
6
6
  import { SPLIT_SIZES } from './knockout-draw.mjs'
7
- import { activePhase, activeSession } from './sessions.mjs'
7
+ import { activePhase, activeRounds, activeSession } from './sessions.mjs'
8
8
 
9
9
 
10
10
  /**
@@ -242,7 +242,7 @@ function placeRound(item, held, day, where) {
242
242
  }
243
243
  }
244
244
 
245
- draw.book({ count: placed, parts, slotIndex, restSlots })
245
+ draw.book({ count: placed, parts, slotIndex, restSlots, date: day.date })
246
246
  }
247
247
 
248
248
  /**
@@ -327,8 +327,11 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
327
327
  const ownSession = activeSession(ownSessions, date)
328
328
  const restSlots = restSlotsFor(ownSession?.playtime ?? MATCH_MINUTES)
329
329
 
330
+ // A slot with no phase of its own belongs to a replan, which owns
331
+ // the whole day whatever the session had meant it for.
330
332
  const firstSlot = own.findIndex(slot =>
331
- (slot.phase ?? activePhase(ownSessions, date, slot.key)) === 'knockout'
333
+ slot.phase === null
334
+ || (slot.phase ?? activePhase(ownSessions, date, slot.key)) === 'knockout'
332
335
  )
333
336
 
334
337
  if (firstSlot === -1) {
@@ -349,6 +352,7 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
349
352
 
350
353
  for (let slotIndex = firstSlot; slotIndex < own.length; slotIndex++) {
351
354
  const slot = own[slotIndex]
355
+ const onRounds = activeRounds(ownSessions, date, slot.key)
352
356
 
353
357
  /** The lane's courts with nothing on them in this slot. */
354
358
  function free() {
@@ -360,7 +364,11 @@ export function assignKnockout(day, { competitions, draws, sessions, split }) {
360
364
  }
361
365
 
362
366
  let ready = units
363
- .filter(draw => draw.readyAt(slotIndex))
367
+ .filter(draw => draw.readyAt(slotIndex, date))
368
+ // A session may say which rounds are on. Nothing else opens
369
+ // until one does, and a draw whose turn has not come waits
370
+ // for the day it was given rather than running ahead.
371
+ .filter(draw => onRounds == null || onRounds.includes(draw.round?.round))
364
372
  .sort((a, b) => (b.width - a.width) || (b.level - a.level))
365
373
 
366
374
  if (ready.length === 0) {
@@ -53,9 +53,16 @@ import { courtCanHost } from './occupancy.mjs'
53
53
  * FOUR_TEAM_TEMPLATE. A round's pairs.length is courtsPerRound.
54
54
  * @param {(group: {id: number, teamIds: number[]}) => Array<{slot: number, pairIndex: number, index: number}>} params.matchesFor
55
55
  * A group's own matches, flattened and indexed in play order.
56
+ * @param {?(groupId: number, index: number) => boolean} [params.done]
57
+ * Which of them are already played, for a day laid out into a plan
58
+ * that exists.
56
59
  * @returns {Array<Array<{groupId: number, index: number}|null>>} slot by court
57
60
  */
58
- export function unitLayout(groups, { template, matchesFor }) {
61
+ export function unitLayout(groups, { template, matchesFor, done = null }) {
62
+ if (done) {
63
+ return remainderLayout(groups, { template, matchesFor, done })
64
+ }
65
+
59
66
  const slots = []
60
67
 
61
68
  for (const round of template) {
@@ -71,7 +78,15 @@ export function unitLayout(groups, { template, matchesFor }) {
71
78
  match => match.slot === round.slot && match.pairIndex === pairIndex
72
79
  )
73
80
 
74
- return index === -1 ? null : { groupId: group.id, index }
81
+ // A fixture already played keeps its place in the
82
+ // numbering and leaves a hole in the layout - the index
83
+ // is what a cell is booked as, so it cannot be squeezed
84
+ // out by renumbering what is left.
85
+ if (index === -1 || done?.(group.id, index)) {
86
+ return null
87
+ }
88
+
89
+ return { groupId: group.id, index }
75
90
  })
76
91
  .filter(Boolean)
77
92
  })
@@ -87,6 +102,54 @@ export function unitLayout(groups, { template, matchesFor }) {
87
102
  return slots
88
103
  }
89
104
 
105
+ /**
106
+ * The same, for groups part-way through.
107
+ *
108
+ * A round with two of its fixtures played is not a round of two with
109
+ * a hole where they were: the hole is a court standing empty and the
110
+ * group finishing later than it needs to. Two groups with three
111
+ * fixtures left each should finish together, this morning, rather
112
+ * than waiting out each other's gaps all day.
113
+ *
114
+ * So the remainder is packed rather than skipped. Each group's own
115
+ * matches keep their order and their numbers - the number is what a
116
+ * cell is booked as - but they are dealt out in turns of
117
+ * courtsPerRound, group after group, until everyone is done. A group
118
+ * with nothing left takes no turn.
119
+ *
120
+ * @param {Array} groups
121
+ * @param {Object} params see unitLayout
122
+ * @returns {Array<Array<{groupId: number, index: number}|null>>}
123
+ */
124
+ function remainderLayout(groups, { template, matchesFor, done }) {
125
+ const width = Math.max(...template.map(round => round.pairs.length))
126
+
127
+ const left = groups.map(group => ({
128
+ id: group.id,
129
+ matches: matchesFor(group)
130
+ .map((match, index) => ({ ...match, index }))
131
+ .filter(match => !done(group.id, match.index)),
132
+ }))
133
+
134
+ const slots = []
135
+
136
+ while (left.some(group => group.matches.length > 0)) {
137
+ for (const group of left) {
138
+ if (group.matches.length === 0) {
139
+ continue
140
+ }
141
+
142
+ const turn = group.matches.splice(0, width)
143
+
144
+ slots.push(Array.from({ length: width }, (_, courtIndex) =>
145
+ turn[courtIndex] ? { groupId: group.id, index: turn[courtIndex].index } : null
146
+ ))
147
+ }
148
+ }
149
+
150
+ return slots
151
+ }
152
+
90
153
  /**
91
154
  * Places an already-computed layout (see unitLayout) onto the lowest
92
155
  * stretch of courts that is actually free, and the earliest start on
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
5
  import { courtUsableAt, runFits } from './court-runs.mjs'
6
+ import { placeLayout } from './paired-court-planner.mjs'
6
7
 
7
8
  /**
8
9
  * How long before dark an unlit court stops taking new play, where the
@@ -180,4 +181,168 @@ export class PlanningDay {
180
181
  this.date, block.court_id, slot.start_minutes, slot.end_minutes
181
182
  )
182
183
  }
184
+
185
+ /**
186
+ * Lays one unit of groups onto this day: its share of the unit's
187
+ * sequence for today, on the courts of its lane.
188
+ *
189
+ * @param {Object} unit
190
+ * @param {Function} unitLayoutFn Lays the unit's groups out in order,
191
+ * leaving out the fixtures its second argument says are done.
192
+ * @param {Array} fallbackSlots Used when the competition has none.
193
+ * @param {Function} done Whether a fixture is already in the plan.
194
+ * @returns {number|boolean} how many courts it took, or false
195
+ */
196
+ placeGroupUnit(unit, unitLayoutFn, fallbackSlots, done) {
197
+ const { date } = this
198
+ const blocks = this.plan.blocks
199
+ const unitDates = unit.dates.slice(0, 2)
200
+ const dayIndex = unitDates.indexOf(date)
201
+
202
+ if (dayIndex === -1 || unit.placed.has(date)) {
203
+ return false
204
+ }
205
+
206
+ const unitSlots = this.slotsByCompetition
207
+ .get(unit.entries[0].competition.id) ?? fallbackSlots
208
+
209
+ if (unitSlots.length === 0 || unitSlots[0].phase === 'knockout') {
210
+ return false
211
+ }
212
+
213
+ const groups = unit.entries.map(entry => ({
214
+ id: entry.group.id,
215
+ teamIds: entry.group.team_ids,
216
+ groupSize: entry.group.pool_size,
217
+ }))
218
+
219
+ // Which fixtures belong to today is a question about the whole
220
+ // unit. Asked of what is left after some were played, the first
221
+ // half of the remainder is not today's half.
222
+ const sequence = unitLayoutFn(groups, () => false)
223
+ const firstDay = Math.floor(sequence.length / unitDates.length)
224
+ const share = dayIndex === 0
225
+ ? sequence.slice(0, firstDay)
226
+ : sequence.slice(firstDay)
227
+
228
+ const fixtures = share.flat().filter(Boolean)
229
+ const owed = new Set(
230
+ fixtures
231
+ .filter(cell => !done(cell.groupId, cell.index))
232
+ .map(cell => `${cell.groupId}:${cell.index}`)
233
+ )
234
+
235
+ const layout = owed.size === fixtures.length
236
+ ? share
237
+ : unitLayoutFn(
238
+ groups, (groupId, index) => !owed.has(`${groupId}:${index}`)
239
+ )
240
+
241
+ const before = blocks.length
242
+
243
+ const placed = placeLayout({
244
+ layout,
245
+ lookup: groupId => {
246
+ const entry = unit.entries.find(x => x.group.id === groupId)
247
+
248
+ return {
249
+ id: entry.group.id,
250
+ competitionId: entry.competition.id,
251
+ divisionId: entry.group.division_id ?? null,
252
+ divisionName: entry.group.division_name ?? null,
253
+ divisionGender: entry.group.division_gender ?? null,
254
+ groupName: entry.group.name ?? null,
255
+ }
256
+ },
257
+ courts: this.courtsFor(unit.isPro),
258
+ slots: unitSlots,
259
+ date,
260
+ occupancy: this.occupancy,
261
+ darkFrom: this.darkFromMinutes,
262
+ blocks,
263
+ })
264
+
265
+ if (placed) {
266
+ unit.placed.add(date)
267
+ return new Set(blocks.slice(before).map(block => block.court_id)).size
268
+ }
269
+
270
+ return false
271
+ }
272
+
273
+ /**
274
+ * Lays out the units that are due today, a lane at a time.
275
+ *
276
+ * A division gets a share of the lane's courts in proportion to its
277
+ * units, so one division cannot take the whole room before another
278
+ * has been looked at. What the shares left over is then offered to
279
+ * whoever has not been placed.
280
+ *
281
+ * @param {Array} units
282
+ * @param {Function} unitLayoutFn Lays a unit's groups out in order.
283
+ * @param {Array} fallbackSlots Used when a competition has none.
284
+ * @param {Function} done Whether a fixture is already in the plan.
285
+ */
286
+ placeGroupUnits(units, unitLayoutFn, fallbackSlots, done) {
287
+ const { date } = this
288
+
289
+ for (const kind of [true, false]) {
290
+ const due = units.filter(unit =>
291
+ unit.isPro === kind && !unit.placed.has(date)
292
+ && unit.dates.includes(date)
293
+ )
294
+
295
+ if (due.length === 0) {
296
+ continue
297
+ }
298
+
299
+ const order = []
300
+
301
+ for (const unit of due) {
302
+ if (!order.includes(unit.divisionId)) {
303
+ order.push(unit.divisionId)
304
+ }
305
+ }
306
+
307
+ const roomCourts = this.courtsFor(kind).length
308
+
309
+ const quota = new Map(order.map((divisionId, index) => {
310
+ const mine = due.filter(unit => unit.divisionId === divisionId)
311
+ const share = mine.length / due.length
312
+
313
+ return [
314
+ divisionId,
315
+ index === order.length - 1
316
+ ? Infinity
317
+ : Math.max(2, Math.round(share * roomCourts)),
318
+ ]
319
+ }))
320
+
321
+ const claimed = new Map()
322
+
323
+ for (const unit of due) {
324
+ const mine = claimed.get(unit.divisionId) ?? 0
325
+
326
+ if (mine >= (quota.get(unit.divisionId) ?? Infinity)) {
327
+ continue
328
+ }
329
+
330
+ const courtsUsed = this.placeGroupUnit(
331
+ unit, unitLayoutFn, fallbackSlots, done
332
+ )
333
+
334
+ if (courtsUsed) {
335
+ claimed.set(
336
+ unit.divisionId, (claimed.get(unit.divisionId) ?? 0) + courtsUsed
337
+ )
338
+ }
339
+ }
340
+
341
+ for (const unit of due) {
342
+ if (!unit.placed.has(date)) {
343
+ this.placeGroupUnit(unit, unitLayoutFn, fallbackSlots, done)
344
+ }
345
+ }
346
+ }
347
+ }
183
348
  }