@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,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
+