@opndev/tennis-match-center 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Changes +66 -1
- package/lib/auto-planner.mjs +1280 -601
- package/lib/components/day-grid.mjs +19 -7
- package/lib/components/gameday-board.mjs +111 -13
- package/lib/components/gameday-card.mjs +31 -0
- package/lib/components/match.mjs +17 -0
- package/lib/components/planning-board.mjs +233 -8
- package/lib/components/roster-board.mjs +42 -2
- package/lib/components/schedule-board.mjs +10 -6
- package/lib/court-bands.mjs +80 -1
- package/lib/four-team-planner.mjs +2 -2
- package/lib/index.mjs +1 -1
- package/lib/knockout-draw.mjs +32 -6
- package/lib/knockout-planner.mjs +12 -4
- package/lib/paired-court-planner.mjs +65 -2
- package/lib/planning-day.mjs +165 -0
- package/lib/run-queue-planner.mjs +167 -11
- package/lib/sessions.mjs +34 -0
- package/lib/three-team-planner.mjs +2 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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