@opndev/tennis-match-center 0.0.1 → 0.0.2

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,325 @@
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
+ import {
8
+ courtName,
9
+ divisionOf,
10
+ effectiveCourtId,
11
+ formatFor,
12
+ hueOf,
13
+ movedCourt,
14
+ segmentOf,
15
+ stageOf,
16
+ startTimeOf,
17
+ stateOf,
18
+ teamName,
19
+ tookOf,
20
+ } from '../gameday-facts.mjs'
21
+
22
+ import { scoreField, teamCell, textTag } from '../gameday-parts.mjs'
23
+
24
+ /**
25
+ * <tennis-gameday-card> - one match on the officials desk.
26
+ *
27
+ * Given a match, the day it is in and the shell describing the event,
28
+ * it draws the line and says what was pressed. It decides nothing: a
29
+ * card never starts a match, ends one or moves a court, it reports
30
+ * that somebody asked for that and the board decides what happens.
31
+ *
32
+ * The shape is a template, `tennis-gameday-card-template`, so a host
33
+ * that wants the line laid out differently authors its own without
34
+ * touching this. Every part is a named hook and a hook that is not in
35
+ * the host's template is simply not drawn, which is what makes an
36
+ * override an override and not a fork.
37
+ *
38
+ * Fired, all bubbling, all carrying `{ match }`:
39
+ *
40
+ * gameday-card:score a score box was typed in
41
+ * gameday-card:action end the match, or reopen it
42
+ * gameday-card:start start the match, or take that back
43
+ * gameday-card:court the court was asked about
44
+ */
45
+ export class TennisGamedayCardElement extends HTMLElementSugar {
46
+ static tag = 'tennis-gameday-card'
47
+
48
+ static HtmlTemplate = ['tennis-gameday-card-template', function () {
49
+ return this.tpl(`
50
+ <span data-identifier></span>
51
+ <span data-badge></span>
52
+ <span data-division></span>
53
+ <span data-took></span>
54
+ <tennis-participant data-home></tennis-participant>
55
+ <span data-scores data-side="home"></span>
56
+ <span data-vs>vs</span>
57
+ <span data-scores data-side="visitor"></span>
58
+ <tennis-participant data-visitor></tennis-participant>
59
+ <button type="button" data-action></button>
60
+ <span data-court-slot></span>
61
+ <button type="button" lead data-start></button>
62
+ `)
63
+ }]
64
+
65
+ connectedCallback() {
66
+ super.connectedCallback()
67
+
68
+ if (!this._drawn) {
69
+ this._drawn = true
70
+ this.appendChild(this.renderFromTemplate())
71
+
72
+ this.addEventListener('input', event => {
73
+ const input = event.target.closest('[data-scores] input')
74
+
75
+ if (input) {
76
+ this.say('score', {
77
+ set: Number(input.dataset.set),
78
+ side: input.closest('[data-scores]').dataset.side,
79
+ value: input.value,
80
+ })
81
+ }
82
+ })
83
+
84
+ this.addEventListener('click', event => {
85
+ const pressed = event.target.closest(
86
+ '[data-court-open], [data-start], [data-action]'
87
+ )
88
+
89
+ if (!pressed || !this.contains(pressed)) {
90
+ return
91
+ }
92
+
93
+ this.say(
94
+ pressed.hasAttribute('data-court-open') ? 'court'
95
+ : pressed.hasAttribute('data-start') ? 'start'
96
+ : 'action'
97
+ )
98
+ })
99
+ }
100
+
101
+ this.render()
102
+ }
103
+
104
+ /**
105
+ * @param {string} what
106
+ * @param {object} detail
107
+ */
108
+ say(what, detail = {}) {
109
+ this.dispatchEvent(new CustomEvent(`gameday-card:${what}`, {
110
+ bubbles: true,
111
+ detail: { match: this.match, ...detail },
112
+ }))
113
+ }
114
+
115
+ render() {
116
+ const match = this.match
117
+ const segment = segmentOf(match)
118
+ const hue = hueOf(this.shell, match)
119
+
120
+ this.dataset.matchId = match.id
121
+ this.dataset.matchIdentifier = match.identifier ?? ''
122
+ this.dataset.status = match.status
123
+ this.dataset.segment = segment
124
+
125
+ if (movedCourt(match)) {
126
+ this.dataset.moved = ''
127
+ } else {
128
+ delete this.dataset.moved
129
+ }
130
+
131
+ if (hue !== undefined && hue !== null) {
132
+ this.style.setProperty('--division-hue', String(hue))
133
+ }
134
+
135
+ this.write('[data-identifier]', match.identifier ?? '')
136
+ this.write('[data-badge]', stageOf(this.day, match))
137
+ this.write('[data-division]', divisionOf(this.shell, match))
138
+ this.write('[data-took]', tookOf(match))
139
+
140
+ this.drawSide('[data-home]', 'home', match.home_team_id)
141
+ this.drawSide('[data-visitor]', 'visitor', match.visitor_team_id)
142
+
143
+ this.drawScores('home')
144
+ this.drawScores('visitor')
145
+
146
+ this.drawAction(segment)
147
+ this.drawStart(segment)
148
+ this.drawCourt(segment)
149
+
150
+ this.title = this.summary()
151
+ }
152
+
153
+ /**
154
+ * @param {string} selector
155
+ * @param {string} text
156
+ */
157
+ write(selector, text) {
158
+ const hook = this.$(selector)
159
+
160
+ if (hook) {
161
+ hook.textContent = text
162
+ }
163
+ }
164
+
165
+ /**
166
+ * A side is a <tennis-participant> the template already holds, so
167
+ * only what it says changes. A host that dropped the element gets a
168
+ * fresh one in the hook it left behind.
169
+ *
170
+ * @param {string} selector
171
+ * @param {string} role
172
+ * @param {number|null} teamId
173
+ */
174
+ drawSide(selector, role, teamId) {
175
+ const hook = this.$(selector)
176
+
177
+ if (!hook) {
178
+ return
179
+ }
180
+
181
+ hook.replaceWith(teamCell(this.day, role, teamId))
182
+ }
183
+
184
+ /**
185
+ * One side's score: a box per set the division's format expects,
186
+ * beside that side's name.
187
+ *
188
+ * A finished match is read only. The result has been reported and
189
+ * filed, so correcting it is a decision an official makes through
190
+ * the board, not something a stray keystroke can do.
191
+ *
192
+ * @param {string} side
193
+ */
194
+ drawScores(side) {
195
+ const column = this.$(`[data-scores][data-side="${side}"]`)
196
+
197
+ if (!column) {
198
+ return
199
+ }
200
+
201
+ const sets = formatFor(this.shell, this.match)?.sets ?? [{}]
202
+ const filed = segmentOf(this.match) === 'finished'
203
+
204
+ if (filed) {
205
+ column.dataset.filed = ''
206
+ } else {
207
+ delete column.dataset.filed
208
+ }
209
+
210
+ column.replaceChildren(...sets.map((set, index) => scoreField({
211
+ set: index + 1,
212
+ value: this.match.sets[index]?.[`${side}_games`],
213
+ label: set.label,
214
+ disabled: filed,
215
+ })))
216
+ }
217
+
218
+ /**
219
+ * Where the run of tabs lands, so it is the one that has to look
220
+ * like the button you are about to press blind.
221
+ *
222
+ * @param {string} segment
223
+ */
224
+ drawAction(segment) {
225
+ const button = this.$('[data-action]')
226
+
227
+ if (!button) {
228
+ return
229
+ }
230
+
231
+ button.textContent = segment === 'finished' ? 'Reopen' : 'End match'
232
+ button.removeAttribute(segment === 'finished' ? 'lead' : 'support')
233
+ if (segment === 'finished') {
234
+ button.setAttribute('support', '')
235
+ }
236
+ else if (segment === 'started') {
237
+ button.setAttribute('lead', '')
238
+ }
239
+ else {
240
+ button.setAttribute('support', '')
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Starting a match is not the thing an official came to the line to
246
+ * do, so it does not compete with End match for the eye. Always
247
+ * drawn, so a line keeps the same cells whatever state it is in.
248
+ *
249
+ * @param {string} segment
250
+ */
251
+ drawStart(segment) {
252
+ const button = this.$('[data-start]')
253
+
254
+ if (!button) {
255
+ return
256
+ }
257
+
258
+ button.textContent = segment === 'started' ? 'Undo start' : 'Start'
259
+ button.removeAttribute(segment === 'started' ? 'lead' : 'support')
260
+ button.setAttribute(segment === 'started' ? 'support' : 'lead', '')
261
+ button.disabled = segment === 'finished'
262
+ }
263
+
264
+ /**
265
+ * A played match is not going to be moved anywhere, so its court is
266
+ * a badge and not something to press. Everything else is one button
267
+ * into one dialog, whether the match has started or not: moving a
268
+ * match is one job and not two depending on when you do it.
269
+ *
270
+ * @param {string} segment
271
+ */
272
+ drawCourt(segment) {
273
+ const hook = this._court ?? this.$('[data-court-slot]')
274
+
275
+ if (!hook) {
276
+ return
277
+ }
278
+
279
+ const said = courtName(this.shell, effectiveCourtId(this.match))
280
+ let control
281
+
282
+ if (segment === 'finished') {
283
+ control = textTag('span', 'court', said)
284
+ } else {
285
+ control = document.createElement('button')
286
+ control.type = 'button'
287
+
288
+ // How loudly a court asks to be looked at, in pinta's own words:
289
+ // the [lead] while it is being played on the court that was
290
+ // planned, an [extra] otherwise. Pinta draws both; the card only
291
+ // says which.
292
+ const leads = segment === 'started' && !movedCourt(this.match)
293
+
294
+ control.setAttribute(leads ? 'lead' : 'extra', '')
295
+ control.dataset.court = ''
296
+ control.dataset.courtOpen = ''
297
+ control.textContent = said
298
+ }
299
+
300
+ hook.replaceWith(control)
301
+ this._court = control
302
+ }
303
+
304
+ /**
305
+ * The line in one sentence, for a tooltip.
306
+ *
307
+ * Who against who, where, and when: the three things somebody says
308
+ * when they walk up to the desk. The line itself has to fit ten
309
+ * columns, so a name it had to cut is readable here.
310
+ *
311
+ * @returns {string}
312
+ */
313
+ summary() {
314
+ const match = this.match
315
+ const sides = `${teamName(this.day, match.home_team_id)} vs ${teamName(this.day, match.visitor_team_id)}`
316
+ const when = tookOf(match) || startTimeOf(match)
317
+
318
+ const said = [sides, courtName(this.shell, effectiveCourtId(match)), when]
319
+ .map(part => String(part || '').trim())
320
+ .filter(Boolean)
321
+ .join(', ')
322
+
323
+ return `${stateOf(match)}: ${said}`
324
+ }
325
+ }
@@ -0,0 +1,199 @@
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
+ import { segmentOf, startTimeOf } from '../gameday-facts.mjs'
8
+ import { textTag } from '../gameday-parts.mjs'
9
+
10
+ import { TennisGamedayCardElement } from './gameday-card.mjs'
11
+
12
+ /**
13
+ * <tennis-gameday-list data-section="pending" heading="Pending">
14
+ *
15
+ * One part of the desk: the matches in one state, grouped by the time
16
+ * they start, each group folding away on its own.
17
+ *
18
+ * The desk has three of these and they differ only in which matches
19
+ * they hold and what they are called, which is why they are one
20
+ * component and not three blocks of the same markup.
21
+ *
22
+ * A block folds with <details> rather than a button of our own: it
23
+ * opens on Enter and on Space, and says whether it is open, without
24
+ * any of that being written here.
25
+ */
26
+ export class TennisGamedayListElement extends HTMLElementSugar {
27
+ static tag = 'tennis-gameday-list'
28
+
29
+ static attributeDefs = {
30
+ heading: {},
31
+ }
32
+
33
+ connectedCallback() {
34
+ super.connectedCallback()
35
+
36
+ this.appendChild(this.title())
37
+ }
38
+
39
+ /**
40
+ * What this list is called. A host that authored its own heading
41
+ * keeps it; the attribute is only the default.
42
+ *
43
+ * @returns {HTMLElement}
44
+ */
45
+ title() {
46
+ return this.$('h2') ?? textTag('h2', 'heading', this.getConfig().heading ?? '')
47
+ }
48
+
49
+ /**
50
+ * Which state this list holds.
51
+ *
52
+ * @returns {string}
53
+ */
54
+ get segment() {
55
+ return this.dataset.section ?? ''
56
+ }
57
+
58
+ /**
59
+ * Draw the matches that belong here, and say nothing at all when
60
+ * none do.
61
+ *
62
+ * @param {Array<object>} matches
63
+ * @param {{day: object, shell: object, folded: Set<string>, openFirst: ?number}} desk
64
+ * openFirst - how many blocks from the top may open. Left out, all
65
+ * of them.
66
+ */
67
+ show(matches, { day, shell, folded, openFirst }) {
68
+ this.day = day
69
+ this.shell = shell
70
+ this.folded = folded
71
+ this.openFirst = openFirst ?? Infinity
72
+
73
+ this.hidden = !matches.length
74
+
75
+ // One operation, so the page loses its height once rather than
76
+ // once per block. The board puts the scroll back after, and it can
77
+ // only do that if the list stops changing height under it.
78
+ this.replaceChildren(this.title(), ...this.timeBlocks(matches))
79
+ }
80
+
81
+ /**
82
+ * @returns {Array<HTMLDetailsElement>}
83
+ */
84
+ blocks() {
85
+ return this.$$('[data-time]')
86
+ }
87
+
88
+ /**
89
+ * @returns {boolean}
90
+ */
91
+ anyOpen() {
92
+ return this.blocks().some(block => block.open)
93
+ }
94
+
95
+ /**
96
+ * @param {boolean} shut
97
+ */
98
+ foldAll(shut) {
99
+ for (const block of this.blocks()) {
100
+ block.open = !shut
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Which blocks are folded shut right now, read off the list before
106
+ * it is redrawn.
107
+ *
108
+ * Asked of the DOM rather than tracked as it happens: <details> is
109
+ * opened by the browser, and its toggle event neither bubbles nor
110
+ * fires the same way everywhere. What is on screen is the one answer
111
+ * that is always right.
112
+ */
113
+ rememberFolds() {
114
+ for (const block of this.blocks()) {
115
+ const key = `${this.segment}:${block.dataset.time}`
116
+
117
+ if (block.open) {
118
+ this.folded.delete(key)
119
+ } else {
120
+ this.folded.add(key)
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The matches as start times, each holding what starts then.
127
+ *
128
+ * @param {Array<object>} matches
129
+ * @returns {Array<HTMLDetailsElement>}
130
+ */
131
+ timeBlocks(matches) {
132
+ const byTime = new Map()
133
+
134
+ for (const match of matches) {
135
+ const time = startTimeOf(match)
136
+
137
+ if (!byTime.has(time)) {
138
+ byTime.set(time, [])
139
+ }
140
+
141
+ byTime.get(time).push(match)
142
+ }
143
+
144
+ return [...byTime.entries()].map(
145
+ ([time, block], index) => this.timeBlock(time, block, index)
146
+ )
147
+ }
148
+
149
+ /**
150
+ * @param {string} time
151
+ * @param {Array<object>} matches
152
+ * @param {number} index - where this block sits in the list
153
+ * @returns {HTMLDetailsElement}
154
+ */
155
+ timeBlock(time, matches, index) {
156
+ const block = document.createElement('details')
157
+ const summary = document.createElement('summary')
158
+ const onCourt = matches.filter(match => segmentOf(match) === 'started')
159
+
160
+ summary.appendChild(textTag('span', 'blockTime', time))
161
+
162
+ // A shut block still has to say it is holding a match somebody is
163
+ // playing right now, because that is the one an official is about
164
+ // to be asked about.
165
+ if (onCourt.length) {
166
+ summary.appendChild(textTag(
167
+ 'span', 'blockOnCourt', `${onCourt.length} on court`
168
+ ))
169
+ }
170
+
171
+ summary.appendChild(textTag('span', 'blockCount', String(matches.length)))
172
+
173
+ block.dataset.time = time
174
+ block.dataset.segment = this.segment
175
+ block.open = index < this.openFirst
176
+ && !this.folded.has(`${this.segment}:${time}`)
177
+ block.appendChild(summary)
178
+
179
+ for (const match of matches) {
180
+ block.appendChild(this.card(match))
181
+ }
182
+
183
+ return block
184
+ }
185
+
186
+ /**
187
+ * @param {object} match
188
+ * @returns {HTMLElement}
189
+ */
190
+ card(match) {
191
+ const card = document.createElement(TennisGamedayCardElement.tag)
192
+
193
+ card.match = match
194
+ card.day = this.day
195
+ card.shell = this.shell
196
+
197
+ return card
198
+ }
199
+ }