@opndev/tennis-match-center 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,603 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { bandDistance, computeSectionBands, sectionsFor } from './court-bands.mjs'
6
+ import { groupRuns } from './group-schedule.mjs'
7
+
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.
13
+ */
14
+ const SECTION_OFFSET = { 0: 2, 1: 3, 2: 0, 3: 1 }
15
+
16
+ // Mirrors GameCounter::GAMES_PER_GROUP server-side: how many matches a
17
+ // full group of this size plays. Used only to report a group with no
18
+ // known match template (see enqueue()'s own guard below) against the
19
+ // same "assume everyone showed up" baseline games_needed itself uses.
20
+ const GAMES_PER_POOL_SIZE = { 3: 3, 4: 6, 5: 10 }
21
+
22
+ const queueKey = (competitionId, date) => `${competitionId}:${date}`
23
+
24
+ /**
25
+ * Group play for any shape whose groups run their whole round robin as
26
+ * one unbroken stretch on one court - 5-team's ten matches over two
27
+ * days, 3-team's three matches in one - as opposed to a 4-team group,
28
+ * which needs a *pair* of courts running two matches at once each
29
+ * round.
30
+ *
31
+ * The unit of allocation is that stretch - a run - not a match, and
32
+ * the day divides into as many sections as whole runs fit in it.
33
+ * prio.pl calls them S1 to S4; they are not clock times but the run
34
+ * itself, so five 45-minute matches make a 3h45 section and a 3-team
35
+ * group's three matches make a shorter one.
36
+ *
37
+ * Two questions have to be answered for every court, and keeping them
38
+ * apart is what makes the result read properly: how many runs a
39
+ * division holds in a section - its quota, settled up front by
40
+ * prepare() - and which court each of those runs lands on, which is
41
+ * its band, a contiguous fraction of the court list.
42
+ *
43
+ * Courts, slots, lighting and the sections themselves belong to the
44
+ * caller. It hands in a court and a section and gets a run back.
45
+ *
46
+ * Short groups are none of this planner's business - they are pooled
47
+ * across divisions onto a shared court pair.
48
+ *
49
+ * Both five-team-planner.mjs and three-team-planner.mjs are thin
50
+ * re-exports of this same engine - the resource model (one run, one
51
+ * court, whole stretch) does not change between those two shapes,
52
+ * only the match template (group-schedule.mjs's GROUP_TEMPLATES)
53
+ * enqueue() reads off the group's own team count.
54
+ */
55
+ export function makeRunQueuePlanner() {
56
+ const groupRunQueue = new Map()
57
+ const sectionRunsByDivision = new Map()
58
+ const bandFractionByDivision = new Map()
59
+ const sectionQuotaByDivision = new Map()
60
+ const sectionCountByDate = new Map()
61
+ const sectionsByGroup = new Map()
62
+ const groupsInSection = new Set()
63
+ const totalRunsByDivision = new Map()
64
+
65
+ const nightSections = new Set()
66
+ const runLengthByDay = new Map()
67
+
68
+ /**
69
+ * How long one run is, in slots.
70
+ *
71
+ * Read off the runs themselves: a run is a stretch of consecutive
72
+ * matches on one court, so its own length is the answer. Taking one
73
+ * figure per competition instead meant the largest group size set
74
+ * the rhythm for every division in it, and a single 5-team draw made
75
+ * a competition of fours tick in fives.
76
+ */
77
+ function runLengthFor(competitionId, date) {
78
+ // Remembered as the runs are queued, not counted off the queue:
79
+ // by the time the plan is reported the queue has been drained,
80
+ // and the answer would be that a run is one slot long.
81
+ return runLengthByDay.get(queueKey(competitionId, date)) ?? 1
82
+ }
83
+
84
+ /**
85
+ * Queues a group's runs against the dates its competition plays.
86
+ *
87
+ * The groups arrive in division-priority order, so the strongest
88
+ * field claims the lowest courts, and a group's own runs stay in
89
+ * template order.
90
+ *
91
+ * @returns {Array<{date: string, count: number}>} the capacity the
92
+ * caller still has to account for, per date.
93
+ */
94
+ function enqueue({ competition, group, dates, asFull = false }) {
95
+ const teams = group.team_ids ?? []
96
+ const size = group.pool_size ?? teams.length
97
+
98
+ // While registration is open a group is planned as the group it
99
+ // will be. A seat still to be filled needs the same court time as
100
+ // one already taken, and planning the four who have entered books
101
+ // a 4-team shape that the fifth then has nowhere to join. The
102
+ // spare cells are the point, not waste.
103
+ const seats = asFull && size > teams.length
104
+ ? Array.from({ length: size }, (_, i) => teams[i] ?? -(i + 1))
105
+ : teams
106
+
107
+ const runs = groupRuns({ teamIds: seats, groupSize: size })
108
+ const needed = []
109
+
110
+ // The days this competition is really in its group phase, not the
111
+ // days its draw asked for. A last group date that is also the day
112
+ // it turns to knockout is not a day this can use.
113
+ const days = dates ?? competition.group_dates ?? []
114
+
115
+ // A real, registered group with no match template at all for its
116
+ // actual team count (group-schedule.mjs's templateFor only covers
117
+ // one vacant seat, not two or more - reachable here once
118
+ // registration closes and asFull stops padding a short group up to
119
+ // its division's full size) plays nothing, silently: without this,
120
+ // the caller never learns it needed any games at all, so it never
121
+ // reaches the shortfall total either. Reported against the same
122
+ // "assume everyone showed up" baseline games_needed itself uses,
123
+ // split evenly across the group's own days like a real template's
124
+ // day split would be.
125
+ if (runs.length === 0 && seats.length > 0 && days.length > 0) {
126
+ const total = GAMES_PER_POOL_SIZE[size] ?? (seats.length * (seats.length - 1)) / 2
127
+ const perDay = Math.floor(total / days.length)
128
+ let remainder = total - perDay * days.length
129
+
130
+ for (const date of days) {
131
+ const count = perDay + (remainder > 0 ? 1 : 0)
132
+ remainder -= 1
133
+
134
+ if (count > 0) {
135
+ needed.push({ date, count })
136
+ }
137
+ }
138
+
139
+ return needed
140
+ }
141
+
142
+ for (const run of runs) {
143
+ // A template that spans two days needs two days to span. Where
144
+ // the competition has only one, the group plays its whole round
145
+ // robin that day rather than losing the half with nowhere to go
146
+ // - ten matches on one court, each of the five teams playing
147
+ // four of them. The 4-team units fold the same way when their
148
+ // second day turns out not to be a group day.
149
+ const date = days.length === 1 ? days[0] : days[run.day - 1]
150
+
151
+ if (!date) {
152
+ continue
153
+ }
154
+
155
+ const key = queueKey(competition.id, date)
156
+
157
+ if (!groupRunQueue.has(key)) {
158
+ groupRunQueue.set(key, [])
159
+ }
160
+
161
+ const divisionKey = `${date}:${group.division_id}`
162
+ totalRunsByDivision.set(divisionKey, (totalRunsByDivision.get(divisionKey) ?? 0) + 1)
163
+
164
+ groupRunQueue.get(key).push({
165
+ day: run.day,
166
+ isPro: group.is_pro === true,
167
+ groupId: group.id,
168
+ groupName: group.name,
169
+ divisionId: group.division_id,
170
+ divisionName: group.division_name,
171
+ divisionGender: group.division_gender,
172
+ indices: run.indices,
173
+ positions: run.positions,
174
+ })
175
+
176
+ runLengthByDay.set(key, Math.max(runLengthByDay.get(key) ?? 1, run.positions.length))
177
+
178
+ needed.push({ date, count: run.positions.length })
179
+ }
180
+
181
+ return needed
182
+ }
183
+
184
+ /**
185
+ * Bands and section quotas for every date that has runs queued.
186
+ *
187
+ * Worked out once, after every competition's runs are known, so they
188
+ * stay put as the queues drain. Reading them off the live queue made
189
+ * the bands drift as runs were consumed, and only the first section
190
+ * of the day came out in order.
191
+ *
192
+ * @param {Object} params
193
+ * @param {Function} params.slotsFor (competitionId, date) => the slots
194
+ * that competition actually plays that day. Sizing the sections off
195
+ * a shared list instead read lit_required from the server, which
196
+ * leaves it unset, so a night section was counted as holding every
197
+ * court when only the lit ones can host it.
198
+ * @param {Array} params.courts The courts these runs may use.
199
+ * @param {Function} params.usable (court, slot) => boolean
200
+ */
201
+ function prepare({ slotsFor, courts, usable }) {
202
+ for (const [key, queue] of groupRunQueue) {
203
+ const date = key.slice(key.indexOf(':') + 1)
204
+ const order = []
205
+
206
+ for (const run of queue) {
207
+ if (!order.includes(run.divisionId)) {
208
+ order.push(run.divisionId)
209
+ }
210
+ }
211
+
212
+ // How many runs each division should hold in each section of this
213
+ // day, worked out up front rather than emerging from the order
214
+ // courts happen to be filled in.
215
+ //
216
+ // Balancing incrementally could not hold both properties at once:
217
+ // judging every court on the running share turned a section into a
218
+ // round robin and interleaved the divisions, while judging it on
219
+ // the band let whoever owned a band take every court in it until
220
+ // their queue ran dry - which emptied the beginners out of S3.
221
+ //
222
+ // Deciding the counts first separates the two questions. How many
223
+ // runs a division gets in a section is settled here; which court
224
+ // each one lands on is left to the band.
225
+ const competitionId = key.slice(0, key.indexOf(':'))
226
+ const runLength = runLengthFor(competitionId, date)
227
+ // Only a section that can hold a whole run counts. Rounding up
228
+ // invented a final section out of the few slots left over, and
229
+ // every run shared out to it was then unplaceable.
230
+ const own = slotsFor(competitionId, date)
231
+
232
+ // Sections are not the same size. S1 and S2 have every court; a
233
+ // night section has only the lit ones, and only those that can
234
+ // host a whole run without the light going partway through. A flat
235
+ // quota therefore under-fills the day sections, the fallback fires
236
+ // to use the spare courts, and the balance it was meant to keep is
237
+ // lost.
238
+ const sections = sectionsFor(own, runLength, { courts, usable })
239
+ const sectionCount = sections.length
240
+ sectionCountByDate.set(date, sectionCount)
241
+
242
+ const capacities = sections.map(section => section.capacity)
243
+
244
+ for (const section of sections) {
245
+ if (section.night) {
246
+ nightSections.add(`${key}:${section.index}`)
247
+ }
248
+ }
249
+
250
+ // Capacity is shared out section by section, against what that
251
+ // section can hold. Slicing each division's own runs across the
252
+ // sections instead over-subscribed the small ones: a night section
253
+ // holding three runs was promising five between two divisions, so
254
+ // whichever was asked first took the lot and the other got none.
255
+ const divisions = order.filter(id => (totalRunsByDivision.get(`${date}:${id}`) ?? 0) > 0)
256
+
257
+ const totals = new Map(
258
+ divisions.map(id => [id, totalRunsByDivision.get(`${date}:${id}`) ?? 0])
259
+ )
260
+
261
+ // The quota/band math itself is shared with every other planner
262
+ // shape - see court-bands.mjs. Only the run-queue bookkeeping
263
+ // (sections, lights, offsets) stays local to this planner.
264
+ const { quotasBySection, bandsBySection } = computeSectionBands({
265
+ divisions,
266
+ totals,
267
+ capacities,
268
+ })
269
+
270
+ for (let section = 0; section < sectionCount; section++) {
271
+ for (const id of divisions) {
272
+ sectionQuotaByDivision.set(`${date}:${section}:${id}`, quotasBySection[section].get(id))
273
+ bandFractionByDivision.set(`${date}:${section}:${id}`, bandsBySection[section].get(id))
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Night capacity is the scarce resource, and taking runs in strict
281
+ * division order let the top division drain it - the strongest field
282
+ * played every night game and three divisions played none at all.
283
+ *
284
+ * The next run goes to whichever division would end up with the
285
+ * smallest share of its own runs in this section - prio.pl's S1 to
286
+ * S4, which are not clock times but the run itself: a section is one
287
+ * group's whole run on one court, so five 45-minute matches make a
288
+ * 3h45 section and a 4-team group's three make a shorter one.
289
+ *
290
+ * Balancing per section spreads every division across the day rather
291
+ * than letting the first division fill the morning and the last take
292
+ * whatever is left. Weighting by share rather than by count keeps it
293
+ * proportional: half the games means half the runs in each section.
294
+ *
295
+ * The +0.5 divisor is deliberate. Comparing the share a division
296
+ * already holds lets a small division jump the queue every time and
297
+ * finish over its proportion; plain +1 biases the other way, toward
298
+ * the large ones.
299
+ *
300
+ * Ties fall to the queue order, which is division priority, so the
301
+ * strongest field takes the lowest-numbered courts.
302
+ */
303
+ function sectionCountFor(date) {
304
+ return sectionCountByDate.get(date) ?? 1
305
+ }
306
+
307
+ function pick(competitionId, date, sectionIndex, court, { proCourt = false } = {}) {
308
+ const queue = groupRunQueue.get(queueKey(competitionId, date))
309
+
310
+ if (!queue || queue.length === 0) {
311
+ return null
312
+ }
313
+
314
+ // A pro court takes a pro division's run and nothing else, and a
315
+ // pro run waits for one rather than taking an open court. Singles
316
+ // carries both kinds, so asking the competition put the beginners
317
+ // on the pro courts and left the pro draws queueing behind them.
318
+ const belongs = run => (run.isPro === true) === proCourt
319
+
320
+ if (!queue.some(belongs)) {
321
+ return { blocked: true }
322
+ }
323
+
324
+ // Where along the courts this one sits, so a run can be judged on
325
+ // how well it belongs here.
326
+ const position = court && court.courtCount > 0
327
+ ? court.courtPosition / court.courtCount
328
+ : 0
329
+
330
+ const scored = queue.map(run => {
331
+ const key = `${date}:${sectionIndex}:${run.divisionId}`
332
+ const used = sectionRunsByDivision.get(key) ?? 0
333
+ const total = totalRunsByDivision.get(`${date}:${run.divisionId}`) ?? 1
334
+
335
+ // The share this division would hold after taking the run, not
336
+ // the one it holds now. Comparing current shares lets a small
337
+ // division jump the queue on every pick and finish over its
338
+ // proportion - a division with half the games took six evenings
339
+ // where five was its share.
340
+ const share = (used + 0.5) / total
341
+
342
+ // Whether this court falls inside the division's own band, and
343
+ // if not how far outside. A band is an interval, not a point:
344
+ // measuring from its start alone hands the wider division too
345
+ // few courts, since every court is near some band's start.
346
+ const band = bandFractionByDivision.get(`${date}:${sectionIndex}:${run.divisionId}`)
347
+ ?? { start: 0, end: 1 }
348
+ const distance = bandDistance(position, band)
349
+
350
+ return { share, step: 1 / total, distance }
351
+ })
352
+
353
+ // Share decides who is owed a run, the band decides where it goes.
354
+ //
355
+ // Neither alone works. Sorting on share first turns the section
356
+ // into a round robin and interleaves the divisions across the
357
+ // courts. Sorting on the band first lets whoever owns a band take
358
+ // every court in it until their queue runs dry, which emptied the
359
+ // beginners out of S3 entirely.
360
+ //
361
+ // The tolerance is one run's worth of share, so a division that
362
+ // has just taken a court is still owed enough to take the one next
363
+ // to it, and its groups stay together without it running away with
364
+ // the section.
365
+ // The quota decides who is owed a run in this section; the band
366
+ // decides which court it lands on. A division that has filled its
367
+ // quota here stands aside, so nobody runs away with a section, and
368
+ // among those still owed a run the court goes to whichever
369
+ // division's band covers it.
370
+ const indexed = scored
371
+ .map((entry, index) => ({ ...entry, index }))
372
+ .filter(entry => belongs(queue[entry.index]))
373
+
374
+ // A group that played under lights yesterday is not asked to do it
375
+ // again. This one is a rule rather than a preference: back-to-back
376
+ // late finishes are what the rotation exists to prevent.
377
+ const eligible = night(competitionId, date, sectionIndex)
378
+ ? indexed.filter(entry => !playedUnderLights(queue[entry.index].groupId, date))
379
+ : indexed
380
+
381
+ // If every run left belongs to a group that played under lights
382
+ // yesterday, this court goes unused. Leaving a lit court idle is
383
+ // the lesser harm: the rotation exists precisely to stop the same
384
+ // group finishing late twice running. Distinct from a null return,
385
+ // which means there is nothing left to place at all.
386
+ if (eligible.length === 0) {
387
+ return { blocked: true }
388
+ }
389
+
390
+ // A group plays one run at a time. Where one group day holds a
391
+ // whole two-day template both halves sit in the same queue, and
392
+ // without this the second was handed the court beside the first -
393
+ // five teams playing ten matches two at a time, every one of them
394
+ // double-booked.
395
+ const free = eligible.filter(
396
+ entry => !groupsInSection.has(`${date}:${sectionIndex}:${queue[entry.index].groupId}`)
397
+ )
398
+
399
+ if (free.length === 0) {
400
+ return { blocked: true }
401
+ }
402
+
403
+ // Nor does any group start its second run until every group has
404
+ // had its first, or one would take the day while another sat out.
405
+ const earliest = Math.min(...free.map(entry => queue[entry.index].day ?? 1))
406
+ const pool = free.filter(entry => (queue[entry.index].day ?? 1) === earliest)
407
+
408
+ const owed = pool.filter(entry => {
409
+ const key = `${date}:${sectionIndex}:${queue[entry.index].divisionId}`
410
+ const quota = sectionQuotaByDivision.get(key) ?? Infinity
411
+
412
+ return (sectionRunsByDivision.get(key) ?? 0) < quota
413
+ })
414
+
415
+ // Rounding, or a section with more room than its quotas asked for,
416
+ // can leave nobody owed. The run still has to go somewhere rather
417
+ // than leave a court idle - but the spare capacity goes to whoever
418
+ // is furthest behind overall, not to whichever division's band
419
+ // happens to cover the leftover courts. Sorting the overflow by
420
+ // band handed every spare court in S1 to one division and emptied
421
+ // it out of S2.
422
+ // Of those still owed a run here, prefer the groups this section
423
+ // is the offset of - S3 wants yesterday's S1, S1 wants yesterday's
424
+ // S3. Guidance rather than a rule: there is rarely room to honour
425
+ // it for every group, so it only orders the choice.
426
+ const wanted = owed.filter(entry => {
427
+ const previous = previousSection(queue[entry.index].groupId, date)
428
+ return previous !== null && SECTION_OFFSET[previous] === sectionIndex
429
+ })
430
+
431
+ // Failing the exact rotation, anything but the same section again.
432
+ // A group's offset target is often a night section with no room
433
+ // left, and without this it simply stayed where it was - half the
434
+ // groups repeating S2 two days running.
435
+ const moved = owed.filter(entry => {
436
+ const previous = previousSection(queue[entry.index].groupId, date)
437
+ return previous === null || previous !== sectionIndex
438
+ })
439
+
440
+ const wantedSet = new Set(wanted)
441
+ const movedSet = new Set(moved)
442
+
443
+ const best = owed.length > 0
444
+ ? owed.reduce((a, b) => {
445
+ // The band comes first, always - which court a division holds
446
+ // is what keeps its groups together. The offset only chooses
447
+ // between two divisions that fit this court equally well; it
448
+ // never pulls a division away from a court its own band
449
+ // already covers in favour of one merely due its rotation.
450
+ // Treating "wanted" as a hard filter did exactly that: a
451
+ // division sitting at distance 0 lost the court to one at
452
+ // distance 0.121 purely because the latter was due its S3-to-
453
+ // S1 offset, splitting the first division's band in two.
454
+ if (b.distance !== a.distance) {
455
+ return b.distance < a.distance ? b : a
456
+ }
457
+
458
+ const aWanted = wantedSet.has(a)
459
+ const bWanted = wantedSet.has(b)
460
+
461
+ if (aWanted !== bWanted) {
462
+ return bWanted ? b : a
463
+ }
464
+
465
+ const aMoved = movedSet.has(a)
466
+ const bMoved = movedSet.has(b)
467
+
468
+ if (aMoved !== bMoved) {
469
+ return bMoved ? b : a
470
+ }
471
+
472
+ return b.share < a.share ? b : a
473
+ })
474
+ : (() => {
475
+ // Spare capacity, beyond what the quotas asked for. It still
476
+ // must not eat a division's later sections: one with four runs
477
+ // and four sections has exactly one for each, and taking two
478
+ // here leaves it absent from the last.
479
+ // Spare capacity, beyond what the quotas asked for. A
480
+ // division may take it only if it will still have a run left
481
+ // for every section after this one - otherwise an extra court
482
+ // now costs it its place later in the day.
483
+ const sectionsLeft = Math.max(0, sectionCountFor(date) - sectionIndex - 1)
484
+
485
+ const canSpare = pool.filter(entry => {
486
+ const division = queue[entry.index].divisionId
487
+ const queued = queue.filter(run => run.divisionId === division).length
488
+
489
+ return queued - 1 >= sectionsLeft
490
+ })
491
+
492
+ // Nobody can take it without stranding themselves, so the
493
+ // court stays free - it was spare to begin with.
494
+ if (canSpare.length === 0) {
495
+ return { blocked: true }
496
+ }
497
+
498
+ const candidates = canSpare
499
+
500
+ // Among those equally far behind, the court still goes to
501
+ // whichever division's band covers it, so the leftover courts
502
+ // at the top of a section join the division already sitting
503
+ // there instead of dragging one up from the bottom.
504
+ const behind = Math.min(...candidates.map(entry => entry.share))
505
+ const slack = Math.max(...candidates.map(entry => entry.step))
506
+
507
+ return candidates
508
+ .filter(entry => entry.share <= behind + slack)
509
+ .reduce((a, b) => (b.distance < a.distance ? b : a))
510
+ })()
511
+
512
+ if (!best) {
513
+ return { blocked: true }
514
+ }
515
+
516
+ return { run: queue[best.index], index: best.index }
517
+ }
518
+
519
+ /**
520
+ * Taking the run is separate from choosing it: the court is picked
521
+ * to suit the run's division, and if no court can host it the run
522
+ * has to stay in the queue for a later slot.
523
+ */
524
+ function commit(competitionId, date, sectionIndex, index) {
525
+ const queue = groupRunQueue.get(queueKey(competitionId, date))
526
+ const [run] = queue.splice(index, 1)
527
+ const key = `${date}:${sectionIndex}:${run.divisionId}`
528
+
529
+ sectionRunsByDivision.set(key, (sectionRunsByDivision.get(key) ?? 0) + 1)
530
+ groupsInSection.add(`${date}:${sectionIndex}:${run.groupId}`)
531
+
532
+ // What this group played today, so tomorrow can be offset from it.
533
+ const history = sectionsByGroup.get(run.groupId) ?? []
534
+ history.push({
535
+ date,
536
+ section: sectionIndex,
537
+ night: night(competitionId, date, sectionIndex),
538
+ })
539
+ sectionsByGroup.set(run.groupId, history)
540
+
541
+ return run
542
+ }
543
+
544
+ /**
545
+ * Whether a section is played under lights.
546
+ *
547
+ * Asked per competition and date, never by section number alone.
548
+ * Competitions do not share a clock: one starting at 16:30 has its
549
+ * section 1 after dark while one starting at 08:00 has its at 11:00,
550
+ * so a bare index let one competition's evening bar every other
551
+ * competition's morning.
552
+ */
553
+ function night(competitionId, date, section) {
554
+ return nightSections.has(`${queueKey(competitionId, date)}:${section}`)
555
+ }
556
+
557
+ /**
558
+ * Whether a group finished under lights on the day it last played.
559
+ * Recorded when the run was taken, because a section number on its
560
+ * own says nothing about the hour it falls in.
561
+ */
562
+ function playedUnderLights(groupId, date) {
563
+ const earlier = (sectionsByGroup.get(groupId) ?? []).filter(entry => entry.date < date)
564
+
565
+ return earlier.length > 0 && earlier[earlier.length - 1].night === true
566
+ }
567
+
568
+ /**
569
+ * The section a group played most recently before this date.
570
+ */
571
+ function previousSection(groupId, date) {
572
+ const history = sectionsByGroup.get(groupId) ?? []
573
+ const earlier = history.filter(entry => entry.date < date)
574
+
575
+ return earlier.length === 0 ? null : earlier[earlier.length - 1].section
576
+ }
577
+
578
+ /**
579
+ * Every competition and date that has runs queued, so the caller can
580
+ * work out each one's sections before play is laid out.
581
+ */
582
+ function queued() {
583
+ return [...groupRunQueue.keys()].map(key => ({
584
+ competitionId: key.slice(0, key.indexOf(':')),
585
+ date: key.slice(key.indexOf(':') + 1),
586
+ }))
587
+ }
588
+
589
+ /**
590
+ * Whether this competition/date has any runs of this planner's own
591
+ * shape still to place. A caller juggling more than one run-queue
592
+ * planner (5-team groups and 3-team groups both feed a run-queue,
593
+ * but from separate instances) uses this to ask which one actually
594
+ * owns a given competition's day, rather than guessing.
595
+ */
596
+ function hasQueued(competitionId, date) {
597
+ const queue = groupRunQueue.get(queueKey(competitionId, date))
598
+
599
+ return (queue?.length ?? 0) > 0
600
+ }
601
+
602
+ return { enqueue, prepare, pick, commit, previousSection, queued, hasQueued, runLengthFor }
603
+ }
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * Whichever session most recently started (on or before this date and
7
+ * hour) determines the active phase - null if no session has started
8
+ * yet.
9
+ */
10
+ export function activePhase(sessions, date, hour) {
11
+ let active = null
12
+
13
+ for (const session of sessions) {
14
+ const started = session.date === date && session.start_time <= hour
15
+
16
+ if (session.date < date || started) {
17
+ active = session.phase
18
+ } else {
19
+ break
20
+ }
21
+ }
22
+
23
+ return active
24
+ }
25
+ /**
26
+ * The slots a competition runs on a date.
27
+ *
28
+ * Its active session says when play starts and how long a match is, so
29
+ * the slots are that start time and one every playtime minutes after.
30
+ * There is no shared grid: doubles opens at 08:00 in 45-minute matches
31
+ * while Team Cup opens at 09:00 in 90-minute ones, and no single ruler
32
+ * measures both.
33
+ *
34
+ * @param {Array<{date: string, start_time: string, playtime: ?number,
35
+ * phase: string}>} sessions
36
+ * in date order
37
+ */
38
+ export function activeSession(sessions, date) {
39
+ let active = null
40
+
41
+ for (const session of sessions) {
42
+ if (session.date <= date) {
43
+ active = session
44
+ }
45
+ }
46
+
47
+ return active
48
+ }