@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,185 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * Shared court-layout math for every planner shape (3/4/5-team groups,
7
+ * knockout): divisions in priority order get a contiguous band, a
8
+ * fraction of the court list, so the strongest field always sits on the
9
+ * lowest courts and its neighbour in priority order sits beside it
10
+ * rather than wherever a "first free court" search happens to land it.
11
+ *
12
+ * Lifted out of five-team-planner.mjs, where this was proven against a
13
+ * real tournament's worth of scheduling before any other planner shape
14
+ * had it at all.
15
+ *
16
+ * @param {Object} params
17
+ * @param {Array<string|number>} params.divisions Priority order - the
18
+ * strongest field first.
19
+ * @param {Map<string|number, number>} params.totals How many runs each
20
+ * division needs across every section, keyed by division id.
21
+ * @param {number[]} params.capacities How many courts each section can
22
+ * actually host (already filtered for lit/usable, per caller).
23
+ * @returns {{
24
+ * quotasBySection: Array<Map<string|number, number>>,
25
+ * bandsBySection: Array<Map<string|number, {start: number, end: number}>>
26
+ * }}
27
+ */
28
+ export function computeSectionBands({ divisions, totals, capacities }) {
29
+ const sectionCount = capacities.length
30
+ const quotasBySection = []
31
+ const bandsBySection = []
32
+ const granted = new Map(divisions.map(id => [id, 0]))
33
+
34
+ for (let section = 0; section < sectionCount; section++) {
35
+ const quotas = new Map(divisions.map(id => [id, 0]))
36
+ let left = capacities[section] ?? 0
37
+
38
+ // One spot each, before anything is shared out proportionally, so a
39
+ // division missing from a section never leaves a group with nowhere
40
+ // to play.
41
+ for (const id of divisions) {
42
+ if (left <= 0) {
43
+ break
44
+ }
45
+
46
+ quotas.set(id, 1)
47
+ left -= 1
48
+ }
49
+
50
+ const runsTotal = divisions.reduce((sum, id) => sum + (totals.get(id) ?? 0), 0)
51
+
52
+ if (runsTotal > 0 && left > 0) {
53
+ // Largest remainder over what is left after the floors.
54
+ const exact = divisions.map(id => (left * (totals.get(id) ?? 0)) / runsTotal)
55
+
56
+ divisions.forEach((id, index) => {
57
+ quotas.set(id, quotas.get(id) + Math.floor(exact[index]))
58
+ })
59
+
60
+ let spare = left - exact.reduce((sum, value) => sum + Math.floor(value), 0)
61
+
62
+ const byRemainder = exact
63
+ .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
64
+ .sort((a, b) => b.remainder - a.remainder)
65
+
66
+ for (const { index } of byRemainder) {
67
+ if (spare <= 0) {
68
+ break
69
+ }
70
+
71
+ quotas.set(divisions[index], quotas.get(divisions[index]) + 1)
72
+ spare -= 1
73
+ }
74
+ }
75
+
76
+ // A division may hold no more of a section than an even share of
77
+ // its day - otherwise one with few runs can be handed a generous
78
+ // quota early and be spent before later sections need it.
79
+ for (const id of divisions) {
80
+ const runs = totals.get(id) ?? 0
81
+ const evenShare = Math.max(1, Math.ceil(runs / sectionCount))
82
+
83
+ quotas.set(id, Math.min(quotas.get(id), evenShare))
84
+ }
85
+
86
+ // Every court of the section is shared out here, not left to a
87
+ // fallback that knows nothing about the bands - that is what
88
+ // scattered a division's tail runs to the end of the row.
89
+ const sectionsLeft = sectionCount - section - 1
90
+ let room = (capacities[section] ?? 0) - divisions.reduce((sum, id) => sum + quotas.get(id), 0)
91
+
92
+ const headroom = id => (totals.get(id) ?? 0) - granted.get(id) - quotas.get(id)
93
+
94
+ while (room > 0) {
95
+ const able = divisions.filter(id => headroom(id) > sectionsLeft)
96
+
97
+ if (able.length === 0) {
98
+ break
99
+ }
100
+
101
+ const next = able.reduce((a, b) => (headroom(b) > headroom(a) ? b : a))
102
+
103
+ quotas.set(next, quotas.get(next) + 1)
104
+ room -= 1
105
+ }
106
+
107
+ // The band a division sits in, as a fraction of the section's own
108
+ // courts, straight from what it was just given. Divisions come in
109
+ // priority order, so the strongest field takes the lowest courts
110
+ // and its neighbour in priority sits beside it.
111
+ const placed = divisions.reduce((sum, id) => sum + quotas.get(id), 0)
112
+ let filled = 0
113
+ const bands = new Map()
114
+
115
+ for (const id of divisions) {
116
+ granted.set(id, granted.get(id) + quotas.get(id))
117
+
118
+ const start = placed === 0 ? 0 : filled / placed
119
+ filled += quotas.get(id)
120
+ const end = placed === 0 ? 1 : filled / placed
121
+
122
+ bands.set(id, { start, end })
123
+ }
124
+
125
+ quotasBySection.push(quotas)
126
+ bandsBySection.push(bands)
127
+ }
128
+
129
+ return { quotasBySection, bandsBySection }
130
+ }
131
+
132
+ /**
133
+ * How far a court (as a fraction of its list) sits from a division's
134
+ * band. Zero inside the band; a band is an interval, not a point, so a
135
+ * court just past either edge is still closer than one at the far end
136
+ * of the row.
137
+ */
138
+ /**
139
+ * Chops a day's slots into named sections - S1, S2, S3, S4 - the unit
140
+ * every planner shape works in, not clock times but a whole run's worth
141
+ * of court time (a 5-team run, a 4-team round, a knockout round). Every
142
+ * planner needs the same answer to "what section is this slot in, and
143
+ * is it played under lights" instead of working it out its own way.
144
+ *
145
+ * Only a section that can hold a whole run counts - rounding up
146
+ * invents a final section out of the few slots left over, and whatever
147
+ * gets shared out to it is then unplaceable.
148
+ *
149
+ * @param {Array} slots The day's slots for one competition, in order.
150
+ * @param {number} runLength How many slots one run/round occupies.
151
+ * @param {Object} [context]
152
+ * @param {Array} [context.courts] The courts these runs may use, so a
153
+ * section's capacity can be reported alongside it.
154
+ * @param {Function} [context.usable] (court, slot) => boolean
155
+ * @returns {Array<{index: number, label: string, slots: Array, night: boolean, capacity: number}>}
156
+ */
157
+ export function sectionsFor(slots, runLength, { courts = [], usable = () => true } = {}) {
158
+ const sectionCount = Math.max(1, Math.floor(slots.length / runLength))
159
+ const sections = []
160
+
161
+ for (let index = 0; index < sectionCount; index++) {
162
+ const first = index * runLength
163
+ const own = slots.slice(first, first + runLength)
164
+
165
+ sections.push({
166
+ index,
167
+ label: `S${index + 1}`,
168
+ slots: own,
169
+ night: own.some(slot => slot.lit_required),
170
+ capacity: own.length === 0
171
+ ? 0
172
+ : courts.filter(court => own.every(slot => usable(court, slot))).length,
173
+ })
174
+ }
175
+
176
+ return sections
177
+ }
178
+
179
+ export function bandDistance(position, band) {
180
+ if (position >= band.start && position < band.end) {
181
+ return 0
182
+ }
183
+
184
+ return Math.min(Math.abs(position - band.start), Math.abs(position - band.end))
185
+ }
@@ -0,0 +1,76 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * Whether a court can host a run of consecutive slots.
7
+ *
8
+ * Lifted out of auto-planner.mjs, where the group planner has always
9
+ * asked this before taking a court: a run goes on a court only when
10
+ * the whole run fits there, never a first slot now and the rest
11
+ * wherever happens to be free later.
12
+ */
13
+
14
+ /**
15
+ * Whether a court may be played on in this slot at all.
16
+ *
17
+ * @param {{lit: boolean, availableFromMinutes: ?number}} court
18
+ * @param {{lit_required: boolean, start_minutes: number}} slot
19
+ * @returns {boolean}
20
+ */
21
+ export function courtUsableAt(court, slot) {
22
+ if (slot.lit_required && !court.lit) {
23
+ return false
24
+ }
25
+
26
+ // A court opened up for the day only from a given time onward (see
27
+ // openCourtsFor) - baked onto the court object itself, so this needs
28
+ // no date of its own to enforce it.
29
+ const opensAt = court.availableFromMinutes
30
+
31
+ if (opensAt != null && slot.start_minutes < opensAt) {
32
+ return false
33
+ }
34
+
35
+ return true
36
+ }
37
+
38
+ /**
39
+ * Whether a run of runLength slots, starting at slotIndex, fits on
40
+ * this court end to end.
41
+ *
42
+ * @param {Object} court
43
+ * @param {Object} context
44
+ * @param {Array} context.slots The asking competition's own slots.
45
+ * @param {number} context.slotIndex Where the run starts.
46
+ * @param {string} context.date
47
+ * @param {Object} context.taken Occupancy (see occupancy.mjs).
48
+ * @param {boolean} [context.requireLit] The run must touch a lit slot.
49
+ * @param {number} runLength
50
+ * @returns {boolean}
51
+ */
52
+ export function runFits(
53
+ court, { slots, slotIndex, date, taken, requireLit }, runLength
54
+ ) {
55
+ if (slotIndex + runLength > slots.length) {
56
+ return false
57
+ }
58
+
59
+ let touchesLit = false
60
+
61
+ for (let i = 0; i < runLength; i++) {
62
+ const slot = slots[slotIndex + i]
63
+
64
+ const vacant = taken.free(
65
+ date, court.id, slot.start_minutes, slot.end_minutes
66
+ )
67
+
68
+ if (!courtUsableAt(court, slot) || !vacant) {
69
+ return false
70
+ }
71
+
72
+ touchesLit = touchesLit || slot.lit_required
73
+ }
74
+
75
+ return requireLit ? touchesLit : true
76
+ }
@@ -0,0 +1,116 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { slotsForDate } from './auto-planner.mjs'
6
+
7
+ /** What an hour is worth when nothing says otherwise. */
8
+ const FALLBACK_SLOT_MINUTES = 45
9
+
10
+ function timeToMinutes(time) {
11
+ const [hours, minutes] = String(time).split(':').map(Number)
12
+
13
+ return hours * 60 + (minutes ?? 0)
14
+ }
15
+
16
+ function minutesToTime(minutes) {
17
+ return `${String(Math.floor(minutes / 60)).padStart(2, '0')}`
18
+ + `:${String(minutes % 60).padStart(2, '0')}`
19
+ }
20
+
21
+ /**
22
+ * The rows one day of the grid has, in order.
23
+ *
24
+ * Competitions do not share a clock: a 90 minute tie and a 45 minute
25
+ * match starting the same morning sit on different hours, so a day
26
+ * carries each competition's own lattice rather than one grid for the
27
+ * event. Planning and Scheduling draw the same rows by asking here.
28
+ *
29
+ * @param {Object} params
30
+ * @param {string} params.date
31
+ * @param {Array} params.competitions Each with its own sessions.
32
+ * @param {Array} params.cells What is already booked that day, so a
33
+ * cell is never stranded on a row the sessions no longer describe.
34
+ * @param {Array} [params.fallbackSlots] Used only where the day has no
35
+ * session at all, so there is still something to drag onto.
36
+ * @returns {Array<{key: string, label: string, start_minutes: number,
37
+ * end_minutes: number}>}
38
+ */
39
+ export function rowsForDate({ date, competitions, cells, fallbackSlots = [] }) {
40
+
41
+ const byKey = new Map()
42
+
43
+ // Only competitions that open on this date, each on its own clock.
44
+ for (const competition of competitions ?? []) {
45
+ const sessions = [...(competition.sessions ?? [])]
46
+ .sort((a, b) =>
47
+ (a.date + a.start_time).localeCompare(b.date + b.start_time)
48
+ )
49
+
50
+ if (!sessions.some(session => session.date === date)) {
51
+ continue
52
+ }
53
+
54
+ // From the hour it holds a court, not the hour its session
55
+ // opens: the leading empty rows of a competition that waits are
56
+ // dropped. What follows its last cell stays.
57
+ const mine = cells
58
+ .filter(cell =>
59
+ cell.date === date && cell.competition_id === competition.id
60
+ )
61
+ .map(cell => cell.slot)
62
+ .sort()
63
+
64
+ for (const slot of slotsForDate(sessions, date, null)) {
65
+ if (mine.length > 0 && slot.key < mine[0]) {
66
+ continue
67
+ }
68
+
69
+ // Two competitions can open at the same minute with different
70
+ // playtimes. The row takes the finer of the two; a coarser
71
+ // competition's own cell still draws its full span.
72
+ const existing = byKey.get(slot.key)
73
+ const duration = slot.end_minutes - slot.start_minutes
74
+
75
+ const finer = existing
76
+ && existing.end_minutes - existing.start_minutes <= duration
77
+
78
+ if (finer) {
79
+ continue
80
+ }
81
+
82
+ byKey.set(slot.key, {
83
+ ...slot,
84
+ label: `${slot.key}-${minutesToTime(slot.end_minutes)}`,
85
+ })
86
+ }
87
+ }
88
+
89
+ // A day nothing is configured for still needs a grid to drag on.
90
+ if (byKey.size === 0) {
91
+ for (const slot of fallbackSlots ?? []) {
92
+ byKey.set(slot.key, slot)
93
+ }
94
+ }
95
+
96
+ // Whatever is already booked, so a block is never stranded on a
97
+ // row the sessions no longer describe - a competition whose
98
+ // knockout runs past the day its session opens, or a plan saved
99
+ // before the sessions were changed. It still needs a real span on
100
+ // the timeline, not just a label, so it gets one at the fallback
101
+ // length.
102
+ for (const cell of cells) {
103
+ if (cell.date === date && !byKey.has(cell.slot)) {
104
+ const start = timeToMinutes(cell.slot)
105
+
106
+ byKey.set(cell.slot, {
107
+ key: cell.slot,
108
+ label: cell.slot,
109
+ start_minutes: start,
110
+ end_minutes: start + FALLBACK_SLOT_MINUTES,
111
+ })
112
+ }
113
+ }
114
+
115
+ return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key))
116
+ }
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * The angle successive divisions are spaced by, in degrees.
7
+ *
8
+ * The golden angle rather than an even split of the circle: an even
9
+ * split over six lands them all in the same part of the spectrum,
10
+ * which reads as six shades of green.
11
+ */
12
+ const GOLDEN_ANGLE = 137.508
13
+
14
+ /**
15
+ * A hue per division, in the order the competitions list them.
16
+ *
17
+ * A hue, not a colour. A board decides which divisions should look
18
+ * different from each other; how strongly that reads is the page's
19
+ * business, so the hue goes out as a custom property and the
20
+ * stylesheet picks saturation and lightness to suit its theme.
21
+ *
22
+ * Planning and Scheduling both draw it, from the same order, so a
23
+ * division a director learned the colour of on one board is that
24
+ * colour on the other.
25
+ *
26
+ * @param {Array<{divisions: ?Array<number|{id: number}>}>} competitions
27
+ * @returns {Map<number, number>}
28
+ */
29
+ export function divisionHues(competitions) {
30
+ const divisionIds = competitions.flatMap(
31
+ competition => (competition.divisions ?? [])
32
+ .map(division => division?.id ?? division)
33
+ )
34
+
35
+ return new Map(
36
+ divisionIds.map((id, index) => [
37
+ id, Math.round((index * GOLDEN_ANGLE) % 360),
38
+ ])
39
+ )
40
+ }
41
+
42
+ /**
43
+ * The hue a competition falls back to where its divisions are not
44
+ * known - an even split of the circle, since there are few of them.
45
+ *
46
+ * @param {number} index
47
+ * @param {number} total
48
+ * @returns {?number}
49
+ */
50
+ export function competitionHue(index, total) {
51
+ return index < 0 ? null : Math.round((index * 360) / Math.max(1, total))
52
+ }
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { makeRunQueuePlanner } from './run-queue-planner.mjs'
6
+
7
+ /**
8
+ * A 5-team division's group play: five teams, one court to itself, the
9
+ * whole ten-match round robin run off across two days as one unbroken
10
+ * stretch per day.
11
+ *
12
+ * This is a thin, shape-named entry point onto run-queue-planner.mjs's
13
+ * shared engine - see that module for how enqueue/prepare/pick/commit
14
+ * actually work. Kept as its own module (rather than callers reaching
15
+ * for makeRunQueuePlanner directly) so a 5-team competition's planner
16
+ * stays its own testable unit, distinct from three-team-planner.mjs's,
17
+ * even though today they share every line of implementation.
18
+ */
19
+ export function makeFiveTeamPlanner() {
20
+ return makeRunQueuePlanner()
21
+ }
package/lib/flags.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import twemoji from '@twemoji/api'
6
+
7
+ /**
8
+ * Writes a name, drawing the flag in front of it as an image where the
9
+ * platform will not draw it as a flag.
10
+ *
11
+ * Windows has no flag glyphs: a regional indicator pair renders as the
12
+ * two letters of the country code, so "AW Alice" where every other
13
+ * platform shows a flag. Parsing the emoji into Twemoji's SVG puts the
14
+ * flag back.
15
+ *
16
+ * @param {HTMLElement} element
17
+ * @param {string} value
18
+ * @param {string} [mode] always, never, or windows (the default):
19
+ * parse only where the platform needs it.
20
+ * @returns {void}
21
+ */
22
+ export function setNameText(element, value, mode = 'windows') {
23
+ element.textContent = value
24
+
25
+ if (!shouldParseFlags(mode)) {
26
+ return
27
+ }
28
+
29
+ twemoji.parse(element, { folder: 'svg', ext: '.svg' })
30
+ }
31
+
32
+ /**
33
+ * @param {string} mode
34
+ * @returns {boolean}
35
+ */
36
+ export function shouldParseFlags(mode = 'windows') {
37
+ const setting = String(mode || 'windows').toLowerCase()
38
+
39
+ if (setting === 'always') {
40
+ return true
41
+ }
42
+
43
+ if (setting === 'never') {
44
+ return false
45
+ }
46
+
47
+ return isWindows()
48
+ }
49
+
50
+ /**
51
+ * @returns {boolean}
52
+ */
53
+ export function isWindows() {
54
+ if (typeof navigator === 'undefined') {
55
+ return false
56
+ }
57
+
58
+ return Boolean(navigator.platform?.startsWith('Win'))
59
+ || Boolean(navigator.userAgent?.includes('Windows'))
60
+ }