@opndev/tennis-match-center 0.0.4 → 0.0.6

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 CHANGED
@@ -1,5 +1,43 @@
1
1
  Revision history for @opndev/tennis-match-center
2
2
 
3
+ 0.0.6 2026-11-06 17:20:26Z
4
+
5
+ * The roster board says when a group plays, above its name, as a
6
+ window such as `Mon 10 Nov, 13:00-16:30`: the first start to the end
7
+ of the last one, from an `ends` list next to `starts`. Without
8
+ `ends` it reads as before.
9
+
10
+ * Groups are named 1, 2, 3, ... instead of A, B, C, ... Z, AA. Bracket
11
+ seeding sorts group names numerically, so 10 follows 9.
12
+
13
+ * A court override can carry `available_until`: the court takes no
14
+ play that ends after it that day. It works on any court, pro or not.
15
+ On a pro court the opening time still only lets regular play in.
16
+
17
+ * The Planning board's right-click menu and legend leave out a
18
+ competition that is not available for planning.
19
+
20
+ * A day can have its own dark from and light buffer. The boards and
21
+ the planner take a `day_lighting` list of `{date, dark_from,
22
+ light_buffer}`, and a day without an entry, or with either left
23
+ empty, keeps the event's.
24
+
25
+ * The Planning board takes `court_overrides` and `day_lighting` from
26
+ the competitions response as well as from the shell, so Auto-fill
27
+ plans against what is saved now rather than what the page loaded
28
+ with. The board also listens for a `planning-settings-saved` event
29
+ on the window and draws again when it arrives; the cells on it are
30
+ not touched.
31
+
32
+ 0.0.5 2026-10-12 14:29:04Z
33
+
34
+ * After dark a knockout round takes the lowest lit courts. Courts shut
35
+ for the dark counted as busy, which drew the round up the row to sit
36
+ beside them.
37
+
38
+ * A round is never cut into uneven quarters or eighths. Ten matches
39
+ halve 5/5 and no longer go 3/3/3/1.
40
+
3
41
  0.0.4 2026-10-10 14:39:58Z
4
42
 
5
43
  * Auto-fill only adds. The Planning board hands the planner the cells
@@ -19,6 +19,7 @@ import { Plan } from './plan.mjs'
19
19
  import { PlanningDay } from './planning-day.mjs'
20
20
  import { activePhase, activeSession } from './sessions.mjs'
21
21
  import { MATCH_MINUTES } from './match-time.mjs'
22
+ import { lightingForDate, relightSlots } from './lighting.mjs'
22
23
 
23
24
  /**
24
25
  * Every day the competitions say they play on: the dates of their
@@ -197,6 +198,7 @@ class AutoPlanner {
197
198
  darkFrom = null,
198
199
  dayEnd = '24:00',
199
200
  lightBuffer = undefined,
201
+ dayLighting = [],
200
202
  groupSlotsFromSessions = false,
201
203
  splitRounds = false,
202
204
  sharing = false,
@@ -307,6 +309,7 @@ class AutoPlanner {
307
309
  darkFrom,
308
310
  dayEnd,
309
311
  lightBuffer,
312
+ dayLighting,
310
313
  groupSlotsFromSessions,
311
314
  splitRounds,
312
315
  sharing,
@@ -433,10 +436,10 @@ class AutoPlanner {
433
436
  overridesByDate.set(override.date, new Map())
434
437
  }
435
438
 
436
- overridesByDate.get(override.date).set(
437
- override.court_id,
438
- override.available_from ? timeToMinutes(override.available_from) : null
439
- )
439
+ overridesByDate.get(override.date).set(override.court_id, {
440
+ from: override.available_from ? timeToMinutes(override.available_from) : null,
441
+ until: override.available_until ? timeToMinutes(override.available_until) : null,
442
+ })
440
443
  }
441
444
 
442
445
  const venue = { overridesByDate, openCourts, proCourts, activeCourts }
@@ -467,14 +470,12 @@ class AutoPlanner {
467
470
  courts,
468
471
  registrationClosed,
469
472
  alwaysByeShortGroups,
470
- lightBuffer,
471
473
  groupSlotsFromSessions,
472
474
  splitRounds,
473
475
  standing,
474
476
  days,
475
477
  competitions,
476
478
  slots,
477
- darkFromMinutes,
478
479
  plan,
479
480
  blocks,
480
481
  remainingGroupByDate,
@@ -514,23 +515,23 @@ class AutoPlanner {
514
515
  const slotsByCompetition = new Map(
515
516
  competitions.map(competition => [
516
517
  competition.id,
517
- competition.slots
518
+ this.litFor(competition.slots
518
519
  ? withMinutes(competition.slots)
519
520
  : ((groupSlotsFromSessions || slots.length === 0)
520
521
  ? this.dayFor(competition, date)
521
- : slots),
522
+ : slots), date),
522
523
  ])
523
524
  )
524
525
 
525
526
  const day = new PlanningDay({
526
527
  date,
527
- proCourts,
528
+ proCourts: this.proCourtsFor(date),
528
529
  openCourts: this.openCourtsFor(date),
529
530
  slotsByCompetition,
530
531
  occupancy: taken,
531
532
  plan,
532
- darkFromMinutes,
533
- lightBufferMinutes: lightBuffer,
533
+ darkFromMinutes: this.darkFromFor(date),
534
+ lightBufferMinutes: this.lightBufferFor(date),
534
535
  })
535
536
 
536
537
  if (this.sharing) {
@@ -554,7 +555,7 @@ class AutoPlanner {
554
555
 
555
556
  const last = own[Math.min(slotIndex + runLength - 1, own.length - 1)]
556
557
  const needsLight = runNeedsLight(
557
- own, slotIndex, runLength, darkFromMinutes
558
+ own, slotIndex, runLength, this.darkFromFor(date)
558
559
  )
559
560
 
560
561
  timeline.push({
@@ -646,7 +647,7 @@ class AutoPlanner {
646
647
  }
647
648
 
648
649
  const laneCourts = entry.group.is_pro === true
649
- ? proCourts
650
+ ? this.proCourtsFor(date)
650
651
  : this.openCourtsFor(date)
651
652
  let landed = false
652
653
 
@@ -1240,12 +1241,12 @@ class AutoPlanner {
1240
1241
  * competitions placed exactly half of theirs.
1241
1242
  */
1242
1243
  groupDatesFor(competition) {
1243
- const { sortedSessions, darkFromMinutes, dayEndMinutes } = this.canvas
1244
+ const { sortedSessions, dayEndMinutes } = this.canvas
1244
1245
 
1245
1246
  const sessions = sortedSessions.get(competition.id) ?? []
1246
1247
 
1247
1248
  return (competition.group_dates ?? []).filter(date => {
1248
- const own = slotsForDate(sessions, date, darkFromMinutes, dayEndMinutes)
1249
+ const own = slotsForDate(sessions, date, this.darkFromFor(date), dayEndMinutes)
1249
1250
 
1250
1251
  return own.length > 0 && own[0].phase === 'group'
1251
1252
  })
@@ -1327,12 +1328,10 @@ class AutoPlanner {
1327
1328
  * phase is on.
1328
1329
  */
1329
1330
  dayFor(competition, on) {
1330
- const {
1331
- sortedSessions, darkFromMinutes, dayEndMinutes,
1332
- } = this.canvas
1331
+ const { sortedSessions, dayEndMinutes } = this.canvas
1333
1332
  const own = sortedSessions.get(competition.id) ?? []
1334
1333
  const fromSessions = slotsForDate(
1335
- own, on, darkFromMinutes, dayEndMinutes, this.anchorFor(on)
1334
+ own, on, this.darkFromFor(on), dayEndMinutes, this.anchorFor(on)
1336
1335
  )
1337
1336
 
1338
1337
  return fromSessions
@@ -1340,19 +1339,54 @@ class AutoPlanner {
1340
1339
 
1341
1340
  slotsFor(competitionId, date) {
1342
1341
  const {
1343
- groupSlotsFromSessions, slots, sortedSessions, darkFromMinutes, dayEndMinutes,
1342
+ groupSlotsFromSessions, slots, sortedSessions, dayEndMinutes,
1344
1343
  } = this.canvas
1345
1344
 
1346
1345
  if (!groupSlotsFromSessions && slots.length > 0) {
1347
- return slots
1346
+ return this.litFor(slots, date)
1348
1347
  }
1349
1348
 
1350
1349
  return slotsForDate(
1351
1350
  sortedSessions.get(Number(competitionId)) ?? [],
1352
- date, darkFromMinutes, dayEndMinutes, this.anchorFor(date)
1351
+ date, this.darkFromFor(date), dayEndMinutes, this.anchorFor(date)
1352
+ )
1353
+ }
1354
+
1355
+ /**
1356
+ * The lighting on this date: the day's own dark from and light
1357
+ * buffer where it has them, the event's otherwise.
1358
+ *
1359
+ * @param {string} date
1360
+ * @returns {{darkFromMinutes: ?number, lightBufferMinutes: (number|undefined), ownDarkFrom: boolean}}
1361
+ */
1362
+ lightingFor(date) {
1363
+ return lightingForDate(
1364
+ this.dayLighting, date, this.darkFromMinutes, this.lightBuffer
1353
1365
  )
1354
1366
  }
1355
1367
 
1368
+ darkFromFor(date) {
1369
+ return this.lightingFor(date).darkFromMinutes
1370
+ }
1371
+
1372
+ lightBufferFor(date) {
1373
+ return this.lightingFor(date).lightBufferMinutes
1374
+ }
1375
+
1376
+ /**
1377
+ * Slots whose lit_required reads off this date's own dark from.
1378
+ * Left as they are when the day has none.
1379
+ *
1380
+ * @param {Array} slots
1381
+ * @param {string} date
1382
+ * @returns {Array}
1383
+ */
1384
+ litFor(slots, date) {
1385
+ const { ownDarkFrom, darkFromMinutes } = this.lightingFor(date)
1386
+
1387
+ return ownDarkFrom ? relightSlots(slots, darkFromMinutes) : slots
1388
+ }
1389
+
1356
1390
  /**
1357
1391
  * The hour the day's first competition opens, which every
1358
1392
  * competition's slots are measured from.
@@ -1394,7 +1428,7 @@ class AutoPlanner {
1394
1428
  * so courtUsableAt can enforce it without needing to know the date.
1395
1429
  */
1396
1430
  openCourtsFor(date) {
1397
- const { overridesByDate, openCourts, proCourts, activeCourts } = this.venue
1431
+ const { overridesByDate, openCourts, activeCourts } = this.venue
1398
1432
 
1399
1433
  const overrides = overridesByDate.get(date)
1400
1434
 
@@ -1402,19 +1436,44 @@ class AutoPlanner {
1402
1436
  return openCourts
1403
1437
  }
1404
1438
 
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
- }]))
1439
+ const shaped = court => this.withOverride(court, overrides.get(court.id))
1410
1440
 
1411
- if (opened.size === 0) {
1441
+ if (!activeCourts.some(court => overrides.has(court.id))) {
1412
1442
  return openCourts
1413
1443
  }
1414
1444
 
1415
1445
  return activeCourts
1416
- .filter(court => !court.pro || opened.has(court.id))
1417
- .map(court => opened.get(court.id) ?? court)
1446
+ .filter(court => !court.pro || overrides.has(court.id))
1447
+ .map(shaped)
1448
+ }
1449
+
1450
+ /**
1451
+ * The pro courts for this date. Only an override's cutoff applies
1452
+ * here: its opening time is about letting regular play in.
1453
+ *
1454
+ * @param {string} date
1455
+ * @returns {Array}
1456
+ */
1457
+ proCourtsFor(date) {
1458
+ const overrides = this.venue.overridesByDate.get(date)
1459
+
1460
+ return this.venue.proCourts.map(court => this.withOverride(
1461
+ court, overrides?.has(court.id)
1462
+ ? { from: null, until: overrides.get(court.id).until }
1463
+ : null
1464
+ ))
1465
+ }
1466
+
1467
+ withOverride(court, override) {
1468
+ if (!override) {
1469
+ return court
1470
+ }
1471
+
1472
+ return {
1473
+ ...court,
1474
+ availableFromMinutes: override.from,
1475
+ availableUntilMinutes: override.until,
1476
+ }
1418
1477
  }
1419
1478
 
1420
1479
  /**
@@ -1584,7 +1643,7 @@ function runNeedsLight(own, slotIndex, runLength, darkFromMinutes) {
1584
1643
  *
1585
1644
  * @param {Array} entries
1586
1645
  * @param {boolean} requireLit
1587
- * @returns {Array<[number, Array]>} Start minute and its entries.
1646
+ * @returns {Array<Array>} Pairs of start minute and its entries.
1588
1647
  */
1589
1648
  function byStartTime(entries, requireLit) {
1590
1649
  const byStart = new Map()
@@ -53,7 +53,9 @@ export function autoFillBracket(seeds) {
53
53
  qualifiers: pools.qualifiers,
54
54
  })
55
55
 
56
- rawMatches = bracket.matches()
56
+ // matches() interleaves the sides. A bracket is the left side, then
57
+ // the right, so two neighbours feed the same next match.
58
+ rawMatches = [...bracket.matches('left'), ...bracket.matches('right')]
57
59
  round = bracket.rounds()[0]
58
60
  }
59
61
 
@@ -112,23 +114,56 @@ function buildMatches(round, firstRoundSlots) {
112
114
  * own representatives are kept apart rather than merely ranked by
113
115
  * overall strength - see <tennis-bracket-board>'s own copy of this
114
116
  * doc comment for the full rationale.
117
+ *
118
+ * The lower tiers are rotated so a group's second place does not draw
119
+ * the side of the bracket its winner is on. How far depends on the
120
+ * draw, so it is the first rotation that leaves no group on one side,
121
+ * found by building the bracket.
115
122
  */
116
- function poolsFromSeeds(seeds) {
117
- const tierNumbers = [...new Set(seeds.map(seed => seed.tier))].sort((a, b) => a - b)
118
- const poolNames = ['seeded', 'unseeded', 'wildcards', 'qualifiers']
123
+ export function poolsFromSeeds(seeds) {
124
+ const draw = Math.max(2, nextPowerOfTwo(seeds.length))
125
+ const tiers = tiersOf(seeds)
126
+ const longest = Math.max(...tiers.map(tier => tier.length))
127
+ const half = Math.floor(longest / 2)
119
128
 
120
- const pools = { seeded: [], unseeded: [], wildcards: [], qualifiers: [] }
129
+ if (draw < 8) {
130
+ return poolsFor(tiers, half)
131
+ }
132
+
133
+ const offsets = [half, ...Array.from({ length: longest }, (_, i) => i).filter(i => i !== half)]
121
134
 
122
- tierNumbers.forEach((tier, tierIndex) => {
123
- const ordered = seeds
124
- .filter(seed => seed.tier === tier)
125
- .sort((a, b) => a.group_name.localeCompare(b.group_name))
126
- .map(seed => String(seed.team_id))
135
+ let best = { offset: half, apart: Infinity }
127
136
 
128
- const rotated = tierIndex === 0
129
- ? ordered
130
- : rotate(ordered, Math.floor(ordered.length / 2))
137
+ for (const offset of offsets) {
138
+ const apart = groupsOnOneSide(seeds, tiers, draw, offset)
139
+
140
+ if (apart < best.apart) {
141
+ best = { offset, apart }
142
+ }
143
+
144
+ if (apart === 0) {
145
+ break
146
+ }
147
+ }
148
+
149
+ return poolsFor(tiers, best.offset)
150
+ }
131
151
 
152
+ function tiersOf(seeds) {
153
+ const tierNumbers = [...new Set(seeds.map(seed => seed.tier))].sort((a, b) => a - b)
154
+
155
+ return tierNumbers.map(tier => seeds
156
+ .filter(seed => seed.tier === tier)
157
+ .sort((a, b) => a.group_name.localeCompare(b.group_name, undefined, { numeric: true }))
158
+ .map(seed => String(seed.team_id)))
159
+ }
160
+
161
+ function poolsFor(tiers, offset) {
162
+ const poolNames = ['seeded', 'unseeded', 'wildcards', 'qualifiers']
163
+ const pools = { seeded: [], unseeded: [], wildcards: [], qualifiers: [] }
164
+
165
+ tiers.forEach((ordered, tierIndex) => {
166
+ const rotated = tierIndex === 0 ? ordered : rotate(ordered, offset % Math.max(ordered.length, 1))
132
167
  const poolName = poolNames[tierIndex] ?? 'qualifiers'
133
168
 
134
169
  pools[poolName].push(...rotated)
@@ -137,6 +172,25 @@ function poolsFromSeeds(seeds) {
137
172
  return pools
138
173
  }
139
174
 
175
+ /**
176
+ * How many groups have a lower tier on the side of the bracket their
177
+ * winner is on. Two of a group on one side meet before the Final.
178
+ */
179
+ function groupsOnOneSide(seeds, tiers, draw, offset) {
180
+ const pools = poolsFor(tiers, offset)
181
+ const bracket = new SingleEliminationBracket({ draw, ...pools })
182
+ const side = new Map()
183
+
184
+ for (const [name, matches] of [['left', bracket.matches('left')], ['right', bracket.matches('right')]]) {
185
+ matches.forEach(match => match.split(' vs ').forEach(team => side.set(team, name)))
186
+ }
187
+
188
+ const group = new Map(seeds.map(seed => [String(seed.team_id), seed.group_name]))
189
+ const winners = new Map(tiers[0].map(team => [group.get(team), side.get(team)]))
190
+
191
+ return tiers.slice(1).flat().filter(team => side.get(team) === winners.get(group.get(team))).length
192
+ }
193
+
140
194
  function rotate(array, offset) {
141
195
  return [...array.slice(offset), ...array.slice(0, offset)]
142
196
  }
@@ -18,7 +18,7 @@ const DEFAULT_LABELS = {
18
18
  * second press cannot start one on top of the first.
19
19
  *
20
20
  * @param {?HTMLElement} button
21
- * @param {() => Promise<boolean>} run Resolves true where the work
21
+ * @param {function(): Promise<boolean>} run Resolves true where the work
22
22
  * succeeded.
23
23
  * @param {{busy: string, done: string, failed: string}} [labels] What
24
24
  * the button says while it works, and afterwards.
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { HTMLElementSugar } from '@skirbi/sugar'
6
6
  import { SingleEliminationBracket } from '../bracket-single-elimination.mjs'
7
+ import { poolsFromSeeds } from '../bracket-autofill.mjs'
7
8
 
8
9
  const ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
9
10
 
@@ -143,7 +144,9 @@ export class TennisBracketBoardElement extends HTMLElementSugar {
143
144
  qualifiers: pools.qualifiers,
144
145
  })
145
146
 
146
- rawMatches = bracket.matches()
147
+ // matches() interleaves the sides. A bracket is the left side, then
148
+ // the right, so two neighbours feed the same next match.
149
+ rawMatches = [...bracket.matches('left'), ...bracket.matches('right')]
147
150
  round = bracket.rounds()[0]
148
151
  }
149
152
 
@@ -280,7 +283,7 @@ export class TennisBracketBoardElement extends HTMLElementSugar {
280
283
  return
281
284
  }
282
285
 
283
- const firstRound = this.matches[0]?.round ?? this.firstRoundSlots[0]?.round
286
+ const firstRound = this.round ?? firstRoundOf(this.matches) ?? this.firstRoundSlots[0]?.round
284
287
  const roundNames = roundsFrom(firstRound ?? ROUND_ORDER.at(-1))
285
288
 
286
289
  let count = slotCount
@@ -473,50 +476,6 @@ function tierLabel(tier) {
473
476
  return { 1: 'Winner', 2: 'Runner-up' }[tier] ?? `${tier}th place`
474
477
  }
475
478
 
476
- /**
477
- * Buckets seeds by advancement tier (1st of each group, 2nd of each
478
- * group, ...) into SingleEliminationBracket's four pools, so a group's
479
- * own representatives are kept apart rather than merely ranked by
480
- * overall strength - the generic seeding algorithm has no notion of
481
- * "these two came from the same source" on its own.
482
- *
483
- * Tier 1 -> seeded, in group order. Every later tier is rotated by
484
- * half its own length before becoming the next pool, so
485
- * pool[i] never comes from the same group as seeded[i]: with the
486
- * algorithm's own "Seed k vs Unseeded k" round-1 pairing, that keeps a
487
- * group's pair in different bracket positions from round 1 onward.
488
- * When the tier count is a power of two this provably keeps a group's
489
- * representatives in opposite halves of the draw (they can only meet
490
- * in the Final) - see bracket-board.test for the worked proof. With an
491
- * uneven group count or more than 4 tiers (this pool mechanism only
492
- * has four) the guarantee is best-effort rather than proven.
493
- */
494
- function poolsFromSeeds(seeds) {
495
- const tierNumbers = [...new Set(seeds.map(seed => seed.tier))].sort((a, b) => a - b)
496
- const poolNames = ['seeded', 'unseeded', 'wildcards', 'qualifiers']
497
-
498
- const pools = { seeded: [], unseeded: [], wildcards: [], qualifiers: [] }
499
-
500
- tierNumbers.forEach((tier, tierIndex) => {
501
- const ordered = seeds
502
- .filter(seed => seed.tier === tier)
503
- .sort((a, b) => a.group_name.localeCompare(b.group_name))
504
- .map(seed => String(seed.team_id))
505
-
506
- const rotated = tierIndex === 0
507
- ? ordered
508
- : rotate(ordered, Math.floor(ordered.length / 2))
509
-
510
- // A 5th+ tier has nowhere to go - the algorithm only has four
511
- // pools - so it folds into qualifiers rather than being dropped.
512
- const poolName = poolNames[tierIndex] ?? 'qualifiers'
513
-
514
- pools[poolName].push(...rotated)
515
- })
516
-
517
- return pools
518
- }
519
-
520
479
  function rotate(array, offset) {
521
480
  return [...array.slice(offset), ...array.slice(0, offset)]
522
481
  }
@@ -231,6 +231,8 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
231
231
  event.preventDefault()
232
232
  this.assignSelection(null)
233
233
  })
234
+
235
+ window.addEventListener('planning-settings-saved', () => this.refreshSettings())
234
236
  }
235
237
 
236
238
  // The shell (courts/days/slots/the already-saved plan) is plain
@@ -285,6 +287,11 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
285
287
  * half, needs re-fetching for that - the grid shell it's layered
286
288
  * onto (courts, days, slots) does not change from a pairing edit.
287
289
  *
290
+ * The response also carries the court overrides and the days' own
291
+ * lighting, which a director edits next to the board. They are taken
292
+ * the same way, so a plan is made against what is saved now. A
293
+ * response without them leaves what the board has.
294
+ *
288
295
  * @returns {Promise<boolean>} false if a newer fetch has since
289
296
  * started and this one's result must be discarded.
290
297
  */
@@ -296,11 +303,36 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
296
303
  }
297
304
 
298
305
  this.state.competitions = data.competitions
306
+
307
+ if (data.court_overrides !== undefined) {
308
+ this.state.court_overrides = data.court_overrides
309
+ }
310
+
311
+ if (data.day_lighting !== undefined) {
312
+ this.state.day_lighting = data.day_lighting
313
+ }
314
+
299
315
  this._divisionHues = null
300
316
 
301
317
  return true
302
318
  }
303
319
 
320
+ /**
321
+ * Draws the board again with what is saved now. Called when the
322
+ * settings next to it were saved: the board is not part of that
323
+ * form, and would otherwise show the old lighting until a reload.
324
+ * The cells on the board are not touched.
325
+ */
326
+ async refreshSettings() {
327
+ if (!this.isConnected) {
328
+ return
329
+ }
330
+
331
+ if (await this.fetchCompetitions()) {
332
+ this.render()
333
+ }
334
+ }
335
+
304
336
  /**
305
337
  * Shared double-connect/stale-request guard: only the result of the
306
338
  * most recently started fetch (shell or competitions, whichever)
@@ -323,11 +355,16 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
323
355
  * Competitions relevant to one day, each tagged with the phase that
324
356
  * applies on that day - group_dates and knockout_dates for the same
325
357
  * competition never overlap, so at most one entry per competition.
358
+ * A competition not available for planning has none.
326
359
  */
327
360
  competitionsForDate(date) {
328
361
  const entries = []
329
362
 
330
363
  for (const competition of this.state.competitions) {
364
+ if (competition.available_for_planning === false) {
365
+ continue
366
+ }
367
+
331
368
  if (competition.group_dates.includes(date)) {
332
369
  entries.push({ competition, phase: 'group' })
333
370
  }
@@ -1310,6 +1347,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
1310
1347
  alwaysByeShortGroups: this.alwaysByeShortGroups(),
1311
1348
  relaxed: this.relaxed(),
1312
1349
  courtOverrides: this.state.court_overrides ?? [],
1350
+ dayLighting: this.state.day_lighting ?? [],
1313
1351
 
1314
1352
  // Group play follows each competition's own session, so a
1315
1353
  // competition starting at 09:00 starts at 09:00. The list is
@@ -1778,6 +1816,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
1778
1816
  fallbackSlots: this.state.slots ?? [],
1779
1817
  darkFromMinutes: toMinutes(this.getConfig()['dark-from']),
1780
1818
  lightBufferMinutes: this.lightBuffer() ?? 0,
1819
+ dayLighting: this.state.day_lighting ?? [],
1781
1820
  })
1782
1821
  }
1783
1822
 
@@ -24,15 +24,21 @@ function dayOf(iso) {
24
24
  }
25
25
 
26
26
  /**
27
- * When a group starts playing, as far as the planning knows it: the
28
- * first match of each day it plays.
27
+ * When a group plays, as far as the planning knows it: from the first
28
+ * match start to the end of the last one, for each day it plays. A day
29
+ * without an end reads as just its start.
29
30
  *
30
31
  * @param {Array<string>} starts - ISO timestamps, one per day
32
+ * @param {Array<string>} [ends] - ISO timestamps, one per day
31
33
  * @returns {string}
32
34
  */
33
- export function startsLine(starts) {
35
+ export function startsLine(starts, ends = []) {
34
36
  return starts
35
- .map(iso => `${dayOf(iso)}, ${iso.slice(11, 16)}`)
37
+ .map((iso, day) => {
38
+ const end = ends[day] ? `-${ends[day].slice(11, 16)}` : ''
39
+
40
+ return `${dayOf(iso)}, ${iso.slice(11, 16)}${end}`
41
+ })
36
42
  .join(' / ')
37
43
  }
38
44
 
@@ -204,11 +210,11 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
204
210
  .map(id => this.saved.teams.find(team => team.id === id))
205
211
  .filter(Boolean)
206
212
 
207
- columns.appendChild(this.columnFor(group.name, teams, group.starts))
213
+ columns.appendChild(this.columnFor(group.name, teams, group.starts, group.ends))
208
214
  }
209
215
  }
210
216
 
211
- columnFor(currentGroupName, teams, starts = []) {
217
+ columnFor(currentGroupName, teams, starts = [], ends = []) {
212
218
  const column = document.createElement('div')
213
219
 
214
220
  column.dataset.group = currentGroupName ?? ''
@@ -234,6 +240,15 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
234
240
  }
235
241
  })
236
242
 
243
+ if (starts?.length) {
244
+ const when = document.createElement('p')
245
+
246
+ when.dataset.planned = ''
247
+ when.title = 'When the group plays, per day'
248
+ when.textContent = startsLine(starts, ends)
249
+ column.appendChild(when)
250
+ }
251
+
237
252
  const heading = document.createElement('h3')
238
253
  heading.textContent = currentGroupName ? `Group ${currentGroupName}` : 'Unassigned'
239
254
  column.appendChild(heading)
@@ -243,15 +258,6 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
243
258
  count.textContent = String(teams.length)
244
259
  heading.appendChild(count)
245
260
 
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
-
255
261
  const list = document.createElement('div')
256
262
  list.dataset.list = ''
257
263
  column.appendChild(list)
@@ -728,6 +728,7 @@ export class TennisScheduleBoardElement extends HTMLElementSugar {
728
728
  cells: this.allCellsWithCompetition(),
729
729
  darkFromMinutes: toMinutes(this.getConfig()['dark-from']),
730
730
  lightBufferMinutes: this.lightBuffer() ?? 0,
731
+ dayLighting: this.state.day_lighting ?? [],
731
732
  })
732
733
  }
733
734
 
@@ -14,7 +14,7 @@
14
14
  /**
15
15
  * Whether a court may be played on in this slot at all.
16
16
  *
17
- * @param {{lit: boolean, availableFromMinutes: ?number}} court
17
+ * @param {{lit: boolean, availableFromMinutes: ?number, availableUntilMinutes: ?number}} court
18
18
  * @param {{lit_required: boolean, start_minutes: number}} slot
19
19
  * @returns {boolean}
20
20
  */
@@ -32,6 +32,12 @@ export function courtUsableAt(court, slot) {
32
32
  return false
33
33
  }
34
34
 
35
+ const closesAt = court.availableUntilMinutes
36
+
37
+ if (closesAt != null && slot.end_minutes > closesAt) {
38
+ return false
39
+ }
40
+
35
41
  return true
36
42
  }
37
43
 
package/lib/day-rows.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
5
  import { slotsForDate } from './auto-planner.mjs'
6
- import { litRequired } from './lighting.mjs'
6
+ import { lightingForDate, litRequired, relightSlots } from './lighting.mjs'
7
7
 
8
8
  /** What an hour is worth when nothing says otherwise. */
9
9
  const FALLBACK_SLOT_MINUTES = 45
@@ -38,13 +38,19 @@ function minutesToTime(minutes) {
38
38
  * row can say whether it needs a lit court.
39
39
  * @param {number} [params.lightBufferMinutes] How long before that an
40
40
  * unlit court stops taking new play.
41
+ * @param {Array} [params.dayLighting] Days with a dark from or a light
42
+ * buffer of their own, which overrule the two above on that date.
41
43
  * @returns {Array<{key: string, label: string, start_minutes: number,
42
44
  * end_minutes: number}>}
43
45
  */
44
46
  export function rowsForDate({
45
47
  date, competitions, cells, fallbackSlots = [],
46
- darkFromMinutes = null, lightBufferMinutes = 0,
48
+ darkFromMinutes: eventDarkFrom = null, lightBufferMinutes: eventBuffer = 0,
49
+ dayLighting = null,
47
50
  }) {
51
+ const { darkFromMinutes, lightBufferMinutes, ownDarkFrom } = lightingForDate(
52
+ dayLighting, date, eventDarkFrom, eventBuffer
53
+ )
48
54
 
49
55
  const byKey = new Map()
50
56
 
@@ -96,7 +102,11 @@ export function rowsForDate({
96
102
 
97
103
  // A day nothing is configured for still needs a grid to drag on.
98
104
  if (byKey.size === 0) {
99
- for (const slot of fallbackSlots ?? []) {
105
+ const fallback = ownDarkFrom
106
+ ? relightSlots(fallbackSlots ?? [], darkFromMinutes)
107
+ : fallbackSlots ?? []
108
+
109
+ for (const slot of fallback) {
100
110
  byKey.set(slot.key, slot)
101
111
  }
102
112
  }
@@ -180,7 +180,8 @@ export function fourTeamMatches(group) {
180
180
  * named entry point onto that shared math.
181
181
  *
182
182
  * @param {number} count
183
- * @param {{dense?: boolean}} options
183
+ * @param {object} [options]
184
+ * @param {boolean} [options.dense]
184
185
  * @returns {number[]} unit sizes, largest first
185
186
  */
186
187
  export function fourTeamUnitSizes(count, { dense = false } = {}) {
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.4";export { VERSION }
5
+ const VERSION = "0.0.6";export { VERSION }
6
6
  export { autoFillBracket } from './bracket-autofill.mjs'
7
7
 
@@ -161,6 +161,10 @@ export class KnockoutDraw {
161
161
  continue
162
162
  }
163
163
 
164
+ if (parts > 2 && this.width % parts !== 0) {
165
+ continue
166
+ }
167
+
164
168
  if (parts > 2 && count < MIN_ROUND_PART) {
165
169
  continue
166
170
  }
@@ -66,7 +66,11 @@ function holdCourts(item, pool, day, where, row) {
66
66
  }
67
67
 
68
68
  const at = court => row.indexOf(court)
69
- const busy = new Set(row.filter(court => !pool.includes(court)).map(at))
69
+ const slot = where.slots[where.slotIndex]
70
+ const usable = new Set(day.usable(row, slot))
71
+ const busy = new Set(
72
+ row.filter(court => usable.has(court) && !pool.includes(court)).map(at)
73
+ )
70
74
  // Where the draw played its last round: one that has to move moves
71
75
  // as little as it can.
72
76
  const were = (item.draw.lastCourts ?? []).map(id => row.findIndex(c => c.id === id))
package/lib/lighting.mjs CHANGED
@@ -17,7 +17,8 @@
17
17
  * attribute on the element; deciding what that means for a court is a
18
18
  * scheduling decision and belongs client-side (spec.md).
19
19
  *
20
- * @param {{slots: Array<{end_minutes?: number}>}} state
20
+ * @param {object} state
21
+ * @param {Array<{end_minutes: (number|undefined)}>} state.slots
21
22
  * @param {string} [darkFromTime] "HH:MM"
22
23
  * @returns {Object} the same state, its slots carrying lit_required
23
24
  */
@@ -94,3 +95,44 @@ export function toMinutes(time) {
94
95
 
95
96
  return Number.isFinite(hours) ? hours * 60 + (Number.isFinite(minutes) ? minutes : 0) : null
96
97
  }
98
+
99
+ /**
100
+ * The lighting that applies on one date: the day's own dark from and
101
+ * light buffer where it has them, the event's where it does not.
102
+ *
103
+ * @param {?Array<{date: string, dark_from: ?string, light_buffer: ?number}>} dayLighting
104
+ * @param {string} date
105
+ * @param {?number} darkFromMinutes The event's.
106
+ * @param {(number|undefined)} lightBufferMinutes The event's.
107
+ * @returns {{darkFromMinutes: ?number, lightBufferMinutes: (number|undefined), ownDarkFrom: boolean}}
108
+ */
109
+ export function lightingForDate(
110
+ dayLighting, date, darkFromMinutes, lightBufferMinutes
111
+ ) {
112
+ const own = (dayLighting ?? []).find(day => day.date === date)
113
+ const ownDarkFrom = Boolean(own?.dark_from)
114
+
115
+ return {
116
+ darkFromMinutes: ownDarkFrom ? toMinutes(own.dark_from) : darkFromMinutes,
117
+ lightBufferMinutes: own?.light_buffer == null
118
+ ? lightBufferMinutes
119
+ : Number(own.light_buffer),
120
+ ownDarkFrom,
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Slots with lit_required read off the given dark from, for a day
126
+ * that has one of its own. The slots on the board carry the event's
127
+ * answer, which that day overrules.
128
+ *
129
+ * @param {Array} slots
130
+ * @param {?number} darkFromMinutes
131
+ * @returns {Array}
132
+ */
133
+ export function relightSlots(slots, darkFromMinutes) {
134
+ return slots.map(slot => ({
135
+ ...slot,
136
+ lit_required: darkFromMinutes != null && slot.end_minutes > darkFromMinutes,
137
+ }))
138
+ }
package/lib/occupancy.mjs CHANGED
@@ -66,6 +66,11 @@ export function courtCanHost(court, slots, darkFromMinutes) {
66
66
  return false
67
67
  }
68
68
 
69
+ if (court.availableUntilMinutes != null
70
+ && slots.some(slot => slot.end_minutes > court.availableUntilMinutes)) {
71
+ return false
72
+ }
73
+
69
74
  if (darkFromMinutes == null || court.lit) {
70
75
  return true
71
76
  }
@@ -51,9 +51,10 @@ import { courtCanHost } from './occupancy.mjs'
51
51
  * group shape's own round robin, one entry per round - see
52
52
  * group-schedule.mjs's GROUP_TEMPLATES and four-team-planner.mjs's
53
53
  * FOUR_TEAM_TEMPLATE. A round's pairs.length is courtsPerRound.
54
- * @param {(group: {id: number, teamIds: number[]}) => Array<{slot: number, pairIndex: number, index: number}>} params.matchesFor
55
- * A group's own matches, flattened and indexed in play order.
56
- * @param {?(groupId: number, index: number) => boolean} [params.done]
54
+ * @param {function(object): Array<object>} params.matchesFor
55
+ * Given a group, {id, teamIds}, its own matches as {slot, pairIndex,
56
+ * index}, flattened and indexed in play order.
57
+ * @param {function(number, number): boolean} [params.done]
57
58
  * Which of them are already played, for a day laid out into a plan
58
59
  * that exists.
59
60
  * @returns {Array<Array<{groupId: number, index: number}|null>>} slot by court
@@ -167,7 +168,8 @@ function remainderLayout(groups, { template, matchesFor, done }) {
167
168
  * slots for no reason.
168
169
  *
169
170
  * @param {Array<Array<{groupId: number, index: number}|null>>} layout
170
- * @param {(groupId: number) => {id, competitionId, divisionId, divisionName, divisionGender, groupName}} lookup
171
+ * @param {function(number): object} lookup Given a group id, its {id,
172
+ * competitionId, divisionId, divisionName, divisionGender, groupName}.
171
173
  */
172
174
  export function placeLayout({ layout, lookup, courts, slots, date, occupancy, darkFrom, blocks }) {
173
175
  if (layout.length === 0) {
@@ -252,7 +254,8 @@ export function placeLayout({ layout, lookup, courts, slots, date, occupancy, da
252
254
  * 7 groups -> 3, 2, 2 9 groups -> 3, 3, 3
253
255
  *
254
256
  * @param {number} count
255
- * @param {{dense?: boolean}} options
257
+ * @param {object} [options]
258
+ * @param {boolean} [options.dense]
256
259
  * @returns {number[]} unit sizes, largest first
257
260
  */
258
261
  export function unitSizes(count, { dense = false } = {}) {
@@ -47,19 +47,11 @@ export function seedGroups(teams, groupCount) {
47
47
  }
48
48
 
49
49
  /**
50
- * A, B, C, ... Z, AA, AB, ... for group naming.
50
+ * 1, 2, 3, ... for group naming.
51
51
  *
52
52
  * @param {number} index zero-based
53
53
  * @returns {string}
54
54
  */
55
55
  export function groupName(index) {
56
- let name = ''
57
- let n = index
58
-
59
- do {
60
- name = String.fromCharCode(65 + (n % 26)) + name
61
- n = Math.floor(n / 26) - 1
62
- } while (n >= 0)
63
-
64
- return name
56
+ return String(index + 1)
65
57
  }
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  },
41
41
  "sideEffects": true,
42
42
  "type": "module",
43
- "version": "0.0.4"
43
+ "version": "0.0.6"
44
44
  }