@opndev/tennis-match-center 0.0.3 → 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.
@@ -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
  }
@@ -6,12 +6,20 @@ import { bandDistance, computeSectionBands, sectionsFor } from './court-bands.mj
6
6
  import { groupRuns } from './group-schedule.mjs'
7
7
 
8
8
  /**
9
- * Where a group should land the day after playing a given section:
10
- * S1 to S3, S2 to S4, S3 to S1, S4 to S2 - prio.pl's rotation. A
11
- * group that played late one day plays early the next and the other
12
- * way round, so nobody gets two late finishes or two early starts.
9
+ * Where a group should land the day after playing a given section: two
10
+ * sections on, wrapping round. With four that is S1 to S3, S2 to S4, S3
11
+ * to S1 and S4 to S2 - prio.pl's rotation, where a group that played
12
+ * late one day plays early the next. With three it is S1 to S3, S2 to
13
+ * S1 and S3 to S2, so nobody plays the same block twice running and
14
+ * nobody who finished late starts early.
15
+ *
16
+ * @param {number} previous The section it played last.
17
+ * @param {number} sectionCount
18
+ * @returns {?number} null where there are too few sections to rotate.
13
19
  */
14
- const SECTION_OFFSET = { 0: 2, 1: 3, 2: 0, 3: 1 }
20
+ function rotationTarget(previous, sectionCount) {
21
+ return sectionCount >= 3 ? (previous + 2) % sectionCount : null
22
+ }
15
23
 
16
24
  // Mirrors GameCounter::GAMES_PER_GROUP server-side: how many matches a
17
25
  // full group of this size plays. Used only to report a group with no
@@ -21,6 +29,102 @@ const GAMES_PER_POOL_SIZE = { 3: 3, 4: 6, 5: 10 }
21
29
 
22
30
  const queueKey = (competitionId, date) => `${competitionId}:${date}`
23
31
 
32
+ /**
33
+ * What is left of a run once the fixtures already in the plan are
34
+ * taken out.
35
+ *
36
+ * @param {{positions: Array<?number>, indices: number[]}} run
37
+ * @param {number} groupId
38
+ * @param {?Function} done (groupId, index) => whether it is in the plan
39
+ * @returns {?Object} the run, shortened, or null when nothing is left
40
+ */
41
+ export function remainderOf(run, groupId, done) {
42
+ if (done === null) {
43
+ return run
44
+ }
45
+
46
+ const owed = run.positions.filter(
47
+ position => position !== null && !done(groupId, position)
48
+ )
49
+
50
+ if (owed.length === 0) {
51
+ return null
52
+ }
53
+
54
+ if (owed.length === run.positions.filter(position => position !== null).length) {
55
+ return run
56
+ }
57
+
58
+ return { ...run, positions: owed, indices: owed }
59
+ }
60
+
61
+ /**
62
+ * How a section can be shared when courts are scarce.
63
+ *
64
+ * A run is one group on one court for its whole stretch. When courts
65
+ * drop out part way through a section, only the courts that last it
66
+ * can host a run, and the rest of the room goes spare - the front of
67
+ * the section has every court, the back only some. Whatever the reason
68
+ * a court drops out, the answer is the same: a group plays the front of
69
+ * its run on a court of its own while that court exists, and the back
70
+ * of it on one of the courts that remain, which two groups share by
71
+ * taking turns. Nobody waits longer than a match for their next.
72
+ *
73
+ * The section is then as long as the front plus two backs.
74
+ *
75
+ * Only the last section that loses courts is shared, and only when
76
+ * sharing holds more groups than whole runs do.
77
+ *
78
+ * @param {Object} params
79
+ * @param {Array} params.own The competition's slots that day.
80
+ * @param {Array} params.sections see sectionsFor
81
+ * @param {number} params.runLength
82
+ * @param {Array} params.courts
83
+ * @param {Function} params.usable (court, slot) => boolean
84
+ * @returns {?{index: number, first: number, front: number, back: number,
85
+ * length: number, capacity: number}}
86
+ */
87
+ export function sharedSectionFor({ own, sections, runLength, courts, usable }) {
88
+ for (let index = sections.length - 1; index >= 0; index--) {
89
+ const first = index * runLength
90
+ const open = courts.filter(court => usable(court, own[first]))
91
+
92
+ let front = 0
93
+
94
+ while (
95
+ front < runLength
96
+ && first + front < own.length
97
+ && open.every(court => usable(court, own[first + front]))
98
+ ) {
99
+ front++
100
+ }
101
+
102
+ // Nothing is lost during the run, or nothing is there to start with.
103
+ if (front === 0 || front === runLength) {
104
+ continue
105
+ }
106
+
107
+ const back = runLength - front
108
+ const length = front + 2 * back
109
+
110
+ if (first + length > own.length) {
111
+ continue
112
+ }
113
+
114
+ const remaining = own.slice(first + front, first + length)
115
+ const lasting = open.filter(court => remaining.every(slot => usable(court, slot)))
116
+ const capacity = Math.min(open.length, lasting.length * 2)
117
+
118
+ if (capacity <= sections[index].capacity) {
119
+ continue
120
+ }
121
+
122
+ return { index, first, front, back, length, capacity }
123
+ }
124
+
125
+ return null
126
+ }
127
+
24
128
  /**
25
129
  * Group play for any shape whose groups run their whole round robin as
26
130
  * one unbroken stretch on one court - 5-team's ten matches over two
@@ -63,6 +167,7 @@ export function makeRunQueuePlanner() {
63
167
  const totalRunsByDivision = new Map()
64
168
 
65
169
  const nightSections = new Set()
170
+ const sharedByKey = new Map()
66
171
  const runLengthByDay = new Map()
67
172
 
68
173
  /**
@@ -91,7 +196,7 @@ export function makeRunQueuePlanner() {
91
196
  * @returns {Array<{date: string, count: number}>} the capacity the
92
197
  * caller still has to account for, per date.
93
198
  */
94
- function enqueue({ competition, group, dates, asFull = false }) {
199
+ function enqueue({ competition, group, dates, asFull = false, done = null }) {
95
200
  const teams = group.team_ids ?? []
96
201
  const size = group.pool_size ?? teams.length
97
202
 
@@ -139,7 +244,16 @@ export function makeRunQueuePlanner() {
139
244
  return needed
140
245
  }
141
246
 
142
- for (const run of runs) {
247
+ for (const template of runs) {
248
+ // What is already in the plan is not owed again. A run with
249
+ // nothing left is dropped, and one part done is what remains of
250
+ // it, packed - on the day the template gave it.
251
+ const run = remainderOf(template, group.id, done)
252
+
253
+ if (run === null) {
254
+ continue
255
+ }
256
+
143
257
  // A template that spans two days needs two days to span. Where
144
258
  // the competition has only one, the group plays its whole round
145
259
  // robin that day rather than losing the half with nowhere to go
@@ -198,7 +312,7 @@ export function makeRunQueuePlanner() {
198
312
  * @param {Array} params.courts The courts these runs may use.
199
313
  * @param {Function} params.usable (court, slot) => boolean
200
314
  */
201
- function prepare({ slotsFor, courts, usable }) {
315
+ function prepare({ slotsFor, courts, usable, sharing = false }) {
202
316
  for (const [key, queue] of groupRunQueue) {
203
317
  const date = key.slice(key.indexOf(':') + 1)
204
318
  const order = []
@@ -241,6 +355,18 @@ export function makeRunQueuePlanner() {
241
355
 
242
356
  const capacities = sections.map(section => section.capacity)
243
357
 
358
+ // Where courts are the limit, the section that loses courts part
359
+ // way through may hold more groups than it has courts for the
360
+ // whole run - see sharedSectionFor.
361
+ const shared = sharing
362
+ ? sharedSectionFor({ own, sections, runLength, courts, usable })
363
+ : null
364
+
365
+ if (shared) {
366
+ capacities[shared.index] = shared.capacity
367
+ sharedByKey.set(key, shared)
368
+ }
369
+
244
370
  for (const section of sections) {
245
371
  if (section.night) {
246
372
  nightSections.add(`${key}:${section.index}`)
@@ -261,10 +387,16 @@ export function makeRunQueuePlanner() {
261
387
  // The quota/band math itself is shared with every other planner
262
388
  // shape - see court-bands.mjs. Only the run-queue bookkeeping
263
389
  // (sections, lights, offsets) stays local to this planner.
390
+ const dates = [...groupRunQueue.keys()]
391
+ .filter(other => other.startsWith(`${competitionId}:`))
392
+ .map(other => other.slice(other.indexOf(':') + 1))
393
+ .sort()
394
+
264
395
  const { quotasBySection, bandsBySection } = computeSectionBands({
265
396
  divisions,
266
397
  totals,
267
398
  capacities,
399
+ rotation: dates.indexOf(date),
268
400
  })
269
401
 
270
402
  for (let section = 0; section < sectionCount; section++) {
@@ -304,7 +436,10 @@ export function makeRunQueuePlanner() {
304
436
  return sectionCountByDate.get(date) ?? 1
305
437
  }
306
438
 
307
- function pick(competitionId, date, sectionIndex, court, { proCourt = false } = {}) {
439
+ function pick(
440
+ competitionId, date, sectionIndex, court,
441
+ { proCourt = false, strictQuota = false } = {}
442
+ ) {
308
443
  const queue = groupRunQueue.get(queueKey(competitionId, date))
309
444
 
310
445
  if (!queue || queue.length === 0) {
@@ -425,7 +560,8 @@ export function makeRunQueuePlanner() {
425
560
  // it for every group, so it only orders the choice.
426
561
  const wanted = owed.filter(entry => {
427
562
  const previous = previousSection(queue[entry.index].groupId, date)
428
- return previous !== null && SECTION_OFFSET[previous] === sectionIndex
563
+ return previous !== null
564
+ && rotationTarget(previous, sectionCountFor(date)) === sectionIndex
429
565
  })
430
566
 
431
567
  // Failing the exact rotation, anything but the same section again.
@@ -440,6 +576,12 @@ export function makeRunQueuePlanner() {
440
576
  const wantedSet = new Set(wanted)
441
577
  const movedSet = new Set(moved)
442
578
 
579
+ // Asked to stay within the quotas, a division that has had its share
580
+ // here gets nothing more, spare room or not.
581
+ if (strictQuota && owed.length === 0) {
582
+ return { blocked: true }
583
+ }
584
+
443
585
  const best = owed.length > 0
444
586
  ? owed.reduce((a, b) => {
445
587
  // The band comes first, always - which court a division holds
@@ -599,5 +741,19 @@ export function makeRunQueuePlanner() {
599
741
  return (queue?.length ?? 0) > 0
600
742
  }
601
743
 
602
- return { enqueue, prepare, pick, commit, previousSection, queued, hasQueued, runLengthFor }
744
+ /**
745
+ * The section, if any, whose run is played on shared courts.
746
+ *
747
+ * @param {number|string} competitionId
748
+ * @param {string} date
749
+ * @returns {?Object} see sharedSectionFor
750
+ */
751
+ function sharedFor(competitionId, date) {
752
+ return sharedByKey.get(queueKey(competitionId, date)) ?? null
753
+ }
754
+
755
+ return {
756
+ enqueue, prepare, pick, commit, previousSection, queued, hasQueued,
757
+ runLengthFor, sharedFor,
758
+ }
603
759
  }
package/lib/sessions.mjs CHANGED
@@ -22,6 +22,40 @@ export function activePhase(sessions, date, hour) {
22
22
 
23
23
  return active
24
24
  }
25
+ /**
26
+ * Which knockout rounds are on, as of this date and hour.
27
+ *
28
+ * A session may name them - "R32 and R16, the 11th, 09:00" - and then
29
+ * those are the only rounds that open from there, in the order the
30
+ * draws come down. A session that names none says nothing about
31
+ * rounds, and whatever the draws have left plays as it always did,
32
+ * which is every competition that has not been told otherwise.
33
+ *
34
+ * Read the same way as the phase: the last session to have started.
35
+ *
36
+ * @param {Array} sessions in date order
37
+ * @param {string} date
38
+ * @param {string} hour
39
+ * @returns {?Array<string>}
40
+ */
41
+ export function activeRounds(sessions, date, hour) {
42
+ let active = null
43
+
44
+ for (const session of sessions) {
45
+ const started = session.date === date && session.start_time <= hour
46
+
47
+ if (session.date < date || started) {
48
+ const named = session.rounds ?? (session.round ? [session.round] : null)
49
+
50
+ active = named?.length ? named : null
51
+ } else {
52
+ break
53
+ }
54
+ }
55
+
56
+ return active
57
+ }
58
+
25
59
  /**
26
60
  * The slots a competition runs on a date.
27
61
  *
@@ -114,9 +114,10 @@ function placeUnit({ unit, courts, slots, date, occupancy, darkFrom, blocks }) {
114
114
  * @param {Array<{id: number, teamIds: number[]}>} groups
115
115
  * @returns {Array<Array<{groupId: number, index: number}|null>>} slot by court
116
116
  */
117
- export function threeTeamUnitLayout(groups) {
117
+ export function threeTeamUnitLayout(groups, done = null) {
118
118
  return unitLayout(groups, {
119
119
  template: GROUP_TEMPLATES[3],
120
120
  matchesFor: group => templateMatches({ teamIds: group.teamIds, groupSize: 3 }),
121
+ done,
121
122
  })
122
123
  }
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  },
41
41
  "sideEffects": true,
42
42
  "type": "module",
43
- "version": "0.0.3"
43
+ "version": "0.0.4"
44
44
  }