@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.
- package/Changes +5 -0
- package/LICENSE +235 -0
- package/README.md +218 -0
- package/lib/auto-planner.mjs +1297 -0
- package/lib/bracket-autofill.mjs +174 -0
- package/lib/bracket-single-elimination.mjs +496 -0
- package/lib/busy-button.mjs +58 -0
- package/lib/components/bracket-board.mjs +586 -0
- package/lib/components/planning-board.mjs +1179 -0
- package/lib/components/roster-board.mjs +273 -0
- package/lib/components/roster-team.mjs +92 -0
- package/lib/components/schedule-board.mjs +1193 -0
- package/lib/court-bands.mjs +185 -0
- package/lib/court-runs.mjs +76 -0
- package/lib/day-rows.mjs +116 -0
- package/lib/division-hues.mjs +52 -0
- package/lib/five-team-planner.mjs +21 -0
- package/lib/flags.mjs +60 -0
- package/lib/four-team-planner.mjs +216 -0
- package/lib/group-schedule.mjs +175 -0
- package/lib/index.mjs +9 -0
- package/lib/knockout-draw.mjs +209 -0
- package/lib/knockout-planner.mjs +573 -0
- package/lib/knockout-waves.mjs +156 -0
- package/lib/lighting.mjs +50 -0
- package/lib/match-time.mjs +13 -0
- package/lib/occupancy.mjs +74 -0
- package/lib/paired-court-planner.mjs +236 -0
- package/lib/plan-placement.mjs +219 -0
- package/lib/plan.mjs +133 -0
- package/lib/planning-day.mjs +177 -0
- package/lib/roster-seeding.mjs +65 -0
- package/lib/run-queue-planner.mjs +603 -0
- package/lib/sessions.mjs +48 -0
- package/lib/three-team-planner.mjs +122 -0
- package/lib/webcomponents.mjs +25 -0
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
- package/package.json +39 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { HTMLElementSugar } from '@skirbi/sugar'
|
|
6
|
+
import { seedGroups, groupName } from '../roster-seeding.mjs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* <tennis-roster-board api="..." save-api="...">
|
|
10
|
+
*
|
|
11
|
+
* Gate 1: Group Creation (spec.md). Loads confirmed teams and any saved
|
|
12
|
+
* groups from `api`, runs the seeding algorithm entirely client-side,
|
|
13
|
+
* and only ever mutates the in-browser copy until Save is pressed.
|
|
14
|
+
*/
|
|
15
|
+
export class TennisRosterBoardElement extends HTMLElementSugar {
|
|
16
|
+
static tag = 'tennis-roster-board'
|
|
17
|
+
static renderGuardSelector = '[data-columns]'
|
|
18
|
+
|
|
19
|
+
static attributeDefs = {
|
|
20
|
+
api: {},
|
|
21
|
+
'save-api': {},
|
|
22
|
+
'csrf-token': {},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static HtmlTemplate = ['tennis-roster-board-template', function () {
|
|
26
|
+
return this.tpl(`
|
|
27
|
+
<div>
|
|
28
|
+
<p data-status>Loading…</p>
|
|
29
|
+
<div data-toolbar hidden>
|
|
30
|
+
<label>
|
|
31
|
+
Pool size
|
|
32
|
+
<select data-pool-size>
|
|
33
|
+
<option value="3">3</option>
|
|
34
|
+
<option value="4">4</option>
|
|
35
|
+
<option value="5">5</option>
|
|
36
|
+
</select>
|
|
37
|
+
</label>
|
|
38
|
+
<button data-prefill type="button">Prefill</button>
|
|
39
|
+
<button data-clear type="button">Clear</button>
|
|
40
|
+
<button data-reset type="button">Reset from database</button>
|
|
41
|
+
<button data-save type="button">Save</button>
|
|
42
|
+
</div>
|
|
43
|
+
<div data-columns></div>
|
|
44
|
+
</div>
|
|
45
|
+
`)
|
|
46
|
+
}]
|
|
47
|
+
|
|
48
|
+
async connectedCallback() {
|
|
49
|
+
super.connectedCallback()
|
|
50
|
+
|
|
51
|
+
// Guard the one-time DOM setup, but always (re)fetch and render
|
|
52
|
+
// below. A morph-driven insertion can disconnect/reconnect this
|
|
53
|
+
// element once synchronously right after creation; if fetch were
|
|
54
|
+
// also skipped on the second connect, whichever instance ends up
|
|
55
|
+
// permanently live would never get real data.
|
|
56
|
+
if (!this.hasRenderedShape()) {
|
|
57
|
+
this.appendChild(this.renderFromTemplate())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!this._listenersBound) {
|
|
61
|
+
this._listenersBound = true
|
|
62
|
+
|
|
63
|
+
this.addEventListener('roster-team-group-change', event => {
|
|
64
|
+
this.moveTeamToGroup(event.detail.teamId, event.detail.groupName)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
this.$('[data-pool-size]').addEventListener('change', event => {
|
|
68
|
+
this.poolSize = Number(event.target.value)
|
|
69
|
+
// Otherwise changing this silently does nothing visible until
|
|
70
|
+
// a separate Prefill click - re-seed immediately so the group
|
|
71
|
+
// columns actually reflect the new size right away. Nothing is
|
|
72
|
+
// persisted until Save either way.
|
|
73
|
+
this.prefill()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
this.$('[data-prefill]').addEventListener('click', () => this.prefill())
|
|
77
|
+
this.$('[data-clear]').addEventListener('click', () => this.clear())
|
|
78
|
+
this.$('[data-reset]').addEventListener('click', () => this.resetFromDatabase())
|
|
79
|
+
this.$('[data-save]').addEventListener('click', () => this.save())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// That same double-connect means two of these fetches can be in
|
|
83
|
+
// flight at once. Without this token, whichever one resolves last
|
|
84
|
+
// wins - including a stray first-connect fetch resolving *after*
|
|
85
|
+
// the user has already clicked Prefill using the second connect's
|
|
86
|
+
// data, silently discarding it. Only the fetch started by the most
|
|
87
|
+
// recent connectedCallback call is allowed to apply its result.
|
|
88
|
+
const requestId = (this._fetchGeneration = (this._fetchGeneration ?? 0) + 1)
|
|
89
|
+
|
|
90
|
+
const { api } = this.getConfig()
|
|
91
|
+
|
|
92
|
+
const response = await fetch(api, {
|
|
93
|
+
headers: { Accept: 'application/json' },
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const data = await response.json()
|
|
97
|
+
|
|
98
|
+
if (requestId !== this._fetchGeneration) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.saved = data
|
|
103
|
+
this.poolSize = this.saved.groups[0]?.pool_size ?? 4
|
|
104
|
+
this.groups = this.saved.groups.map(group => ({ ...group }))
|
|
105
|
+
|
|
106
|
+
this.$('[data-pool-size]').value = String(this.poolSize)
|
|
107
|
+
|
|
108
|
+
this.render()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
unassignedTeams() {
|
|
112
|
+
const assigned = new Set(this.groups.flatMap(group => group.team_ids))
|
|
113
|
+
|
|
114
|
+
return this.saved.teams.filter(team => !assigned.has(team.id))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
prefill() {
|
|
118
|
+
const groupCount = Math.max(1, Math.ceil(this.saved.teams.length / this.poolSize))
|
|
119
|
+
const seeded = seedGroups(this.saved.teams, groupCount)
|
|
120
|
+
|
|
121
|
+
this.groups = seeded.map((teamIds, index) => ({
|
|
122
|
+
id: null,
|
|
123
|
+
name: groupName(index),
|
|
124
|
+
pool_size: this.poolSize,
|
|
125
|
+
team_ids: teamIds,
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
this.render()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clear() {
|
|
132
|
+
this.groups = []
|
|
133
|
+
this.render()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
resetFromDatabase() {
|
|
137
|
+
this.groups = this.saved.groups.map(group => ({ ...group }))
|
|
138
|
+
this.render()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
moveTeamToGroup(teamId, targetGroupName) {
|
|
142
|
+
for (const group of this.groups) {
|
|
143
|
+
group.team_ids = group.team_ids.filter(id => id !== teamId)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (targetGroupName) {
|
|
147
|
+
const group = this.groups.find(item => item.name === targetGroupName)
|
|
148
|
+
|
|
149
|
+
if (group) {
|
|
150
|
+
group.team_ids.push(teamId)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.dispatchEvent(new CustomEvent('tennis-roster:changed', {
|
|
155
|
+
bubbles: true,
|
|
156
|
+
detail: { groups: this.groups },
|
|
157
|
+
}))
|
|
158
|
+
|
|
159
|
+
this.render()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
render() {
|
|
163
|
+
this.$('[data-status]').hidden = true
|
|
164
|
+
this.$('[data-toolbar]').hidden = false
|
|
165
|
+
|
|
166
|
+
const columns = this.$('[data-columns]')
|
|
167
|
+
columns.replaceChildren()
|
|
168
|
+
|
|
169
|
+
columns.appendChild(this.columnFor(null, this.unassignedTeams()))
|
|
170
|
+
|
|
171
|
+
for (const group of this.groups) {
|
|
172
|
+
const teams = group.team_ids
|
|
173
|
+
.map(id => this.saved.teams.find(team => team.id === id))
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
|
|
176
|
+
columns.appendChild(this.columnFor(group.name, teams))
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
columnFor(currentGroupName, teams) {
|
|
181
|
+
const column = document.createElement('div')
|
|
182
|
+
|
|
183
|
+
column.dataset.group = currentGroupName ?? ''
|
|
184
|
+
|
|
185
|
+
column.addEventListener('dragover', event => {
|
|
186
|
+
event.preventDefault()
|
|
187
|
+
event.dataTransfer.dropEffect = 'move'
|
|
188
|
+
column.dataset.dragover = ''
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
column.addEventListener('dragleave', () => {
|
|
192
|
+
delete column.dataset.dragover
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
column.addEventListener('drop', event => {
|
|
196
|
+
event.preventDefault()
|
|
197
|
+
delete column.dataset.dragover
|
|
198
|
+
|
|
199
|
+
const teamId = Number(event.dataTransfer.getData('text/plain'))
|
|
200
|
+
|
|
201
|
+
if (Number.isFinite(teamId)) {
|
|
202
|
+
this.moveTeamToGroup(teamId, currentGroupName)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const heading = document.createElement('h3')
|
|
207
|
+
heading.textContent = currentGroupName ? `Group ${currentGroupName}` : 'Unassigned'
|
|
208
|
+
column.appendChild(heading)
|
|
209
|
+
|
|
210
|
+
const count = document.createElement('span')
|
|
211
|
+
count.dataset.count = ''
|
|
212
|
+
count.textContent = String(teams.length)
|
|
213
|
+
heading.appendChild(count)
|
|
214
|
+
|
|
215
|
+
const list = document.createElement('div')
|
|
216
|
+
list.dataset.list = ''
|
|
217
|
+
column.appendChild(list)
|
|
218
|
+
|
|
219
|
+
const groupNames = this.groups.map(group => group.name)
|
|
220
|
+
|
|
221
|
+
for (const team of teams) {
|
|
222
|
+
const element = document.createElement('tennis-roster-team')
|
|
223
|
+
|
|
224
|
+
element.team = team
|
|
225
|
+
element.groupNames = groupNames
|
|
226
|
+
element.currentGroupName = currentGroupName
|
|
227
|
+
|
|
228
|
+
list.appendChild(element)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return column
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async save() {
|
|
235
|
+
const config = this.getConfig()
|
|
236
|
+
|
|
237
|
+
const groups = this.groups.map(group => ({
|
|
238
|
+
...group,
|
|
239
|
+
pool_size: this.poolSize,
|
|
240
|
+
}))
|
|
241
|
+
|
|
242
|
+
const response = await fetch(config['save-api'], {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: {
|
|
245
|
+
Accept: 'application/json',
|
|
246
|
+
'Content-Type': 'application/json',
|
|
247
|
+
'X-CSRF-TOKEN': config['csrf-token'],
|
|
248
|
+
},
|
|
249
|
+
body: JSON.stringify({ groups }),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const body = await response.json()
|
|
253
|
+
|
|
254
|
+
if (!response.ok) {
|
|
255
|
+
this.dispatchEvent(new CustomEvent('tennis-roster:save-error', {
|
|
256
|
+
bubbles: true,
|
|
257
|
+
detail: body,
|
|
258
|
+
}))
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.saved = body
|
|
263
|
+
this.groups = this.saved.groups.map(group => ({ ...group }))
|
|
264
|
+
|
|
265
|
+
this.dispatchEvent(new CustomEvent('tennis-roster:save', {
|
|
266
|
+
bubbles: true,
|
|
267
|
+
detail: { state: this.saved },
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
this.render()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { HTMLElementSugar } from '@skirbi/sugar'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One team entry in the Roster board. Draggable between groups (see
|
|
9
|
+
* "Manual Override" in spec.md); the select is kept as an equivalent,
|
|
10
|
+
* keyboard/screen-reader-reachable way to do the same move.
|
|
11
|
+
*/
|
|
12
|
+
export class TennisRosterTeamElement extends HTMLElementSugar {
|
|
13
|
+
static tag = 'tennis-roster-team'
|
|
14
|
+
static HtmlTemplate = ['tennis-roster-team-template', function () {
|
|
15
|
+
return this.tpl(`
|
|
16
|
+
<article data-card>
|
|
17
|
+
<p data-label></p>
|
|
18
|
+
<select data-group></select>
|
|
19
|
+
</article>
|
|
20
|
+
`)
|
|
21
|
+
}]
|
|
22
|
+
|
|
23
|
+
connectedCallback() {
|
|
24
|
+
super.connectedCallback()
|
|
25
|
+
|
|
26
|
+
this.appendChild(this.renderFromTemplate())
|
|
27
|
+
|
|
28
|
+
this.$('[data-group]').addEventListener('change', event => {
|
|
29
|
+
this.dispatchEvent(new CustomEvent('roster-team-group-change', {
|
|
30
|
+
bubbles: true,
|
|
31
|
+
detail: {
|
|
32
|
+
teamId: this.team.id,
|
|
33
|
+
groupName: event.target.value || null,
|
|
34
|
+
},
|
|
35
|
+
}))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
this.draggable = true
|
|
39
|
+
|
|
40
|
+
this.addEventListener('dragstart', event => {
|
|
41
|
+
event.dataTransfer.setData('text/plain', String(this.team.id))
|
|
42
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
43
|
+
this.dataset.dragging = ''
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
this.addEventListener('dragend', () => {
|
|
47
|
+
delete this.dataset.dragging
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
this.render()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
render() {
|
|
54
|
+
this.dataset.teamId = this.team.id
|
|
55
|
+
|
|
56
|
+
this.$('[data-label]').textContent = this.label()
|
|
57
|
+
|
|
58
|
+
const select = this.$('[data-group]')
|
|
59
|
+
select.replaceChildren()
|
|
60
|
+
|
|
61
|
+
const unassigned = document.createElement('option')
|
|
62
|
+
unassigned.value = ''
|
|
63
|
+
unassigned.textContent = 'Unassigned'
|
|
64
|
+
unassigned.selected = !this.currentGroupName
|
|
65
|
+
select.appendChild(unassigned)
|
|
66
|
+
|
|
67
|
+
for (const groupName of this.groupNames) {
|
|
68
|
+
const option = document.createElement('option')
|
|
69
|
+
|
|
70
|
+
option.value = groupName
|
|
71
|
+
option.textContent = `Group ${groupName}`
|
|
72
|
+
option.selected = groupName === this.currentGroupName
|
|
73
|
+
|
|
74
|
+
select.appendChild(option)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
label() {
|
|
79
|
+
const parts = [this.team.display_name]
|
|
80
|
+
|
|
81
|
+
if (this.team.country) {
|
|
82
|
+
parts.push(this.team.country)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.team.age != null) {
|
|
86
|
+
parts.push(`${this.team.age}y`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return parts.join(' · ')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|