@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,368 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { HTMLElementSugar, withConnectedSucu } from '@skirbi/sugar'
6
+
7
+ /**
8
+ * <tennis-match> - one match, wherever it is shown.
9
+ *
10
+ * The two sides are whatever the host puts inside; this renders the
11
+ * chrome beside them. Naming a participant is the application's job.
12
+ *
13
+ * @example
14
+ * <tennis-match court="Court 13" division="Beginner" stage="Group A" ended>
15
+ * <abto-participant side="home" score="7,6,10" label="…"></abto-participant>
16
+ * <abto-participant side="guest" score="3,7,8" label="…"></abto-participant>
17
+ * </tennis-match>
18
+ *
19
+ * The score is optional.
20
+ *
21
+ * Authored children stay where they are, positioned with CSS `order`
22
+ * rather than moved into slots: a Livewire morph fights moved nodes.
23
+ */
24
+ export class TennisMatchElement extends withConnectedSucu(HTMLElementSugar) {
25
+ static tag = 'tennis-match'
26
+
27
+ static exampleHTML = '<tennis-match court="Court 8"></tennis-match>'
28
+ static exampleRenderedHTML = '<span tennis-match-court>Court 8</span>'
29
+
30
+ static renderGuardSelector = ':scope > [tennis-match-chrome]'
31
+
32
+ static attributeDefs = {
33
+ court: { default: '' },
34
+ division: { default: '' },
35
+ stage: { default: '' },
36
+ 'start-time': { default: '' },
37
+ 'duration': { default: '' },
38
+ started: { default: null },
39
+ ended: { default: null },
40
+ 'on-court': { default: null },
41
+ status: { default: '' },
42
+ winner: { default: '' },
43
+ hue: { default: '' },
44
+ }
45
+
46
+ static HtmlTemplate = ['tennis-match-template', function () {
47
+ return this.tpl(`
48
+ <span tennis-match-chrome>
49
+ <span tennis-match-meta>
50
+ <span tennis-match-badge></span>
51
+ <span tennis-match-division></span>
52
+ <span tennis-match-court></span>
53
+ <span tennis-match-time></span>
54
+ <span tennis-match-state></span>
55
+ </span>
56
+ <span tennis-match-versus></span>
57
+ <span tennis-match-score data-for="home"></span>
58
+ <span tennis-match-score data-for="guest"></span>
59
+ </span>
60
+ `)
61
+ }]
62
+
63
+ connectedCallbackSugar() {
64
+ this.render()
65
+ }
66
+
67
+ updatedAttributeSugar(name, oldValue, newValue) {
68
+ if (oldValue !== newValue) {
69
+ this.render()
70
+ }
71
+ }
72
+
73
+ morphedCallbackSugar() {
74
+ this.render()
75
+ }
76
+
77
+ /**
78
+ * @param {string} side - 'home' or 'guest'
79
+ * @returns {Element|null}
80
+ */
81
+ sideElement(side) {
82
+ return this.querySelector(`:scope > [side="${side}"]`)
83
+ }
84
+
85
+ /**
86
+ * A side's score as numbers, one per set.
87
+ *
88
+ * @param {string} side
89
+ * @returns {Array<number>}
90
+ */
91
+ scoreOf(side) {
92
+ const raw = this.sideElement(side)?.getAttribute('score') ?? ''
93
+
94
+ return raw
95
+ .split(',')
96
+ .map(part => part.trim())
97
+ .filter(part => part !== '')
98
+ .map(Number)
99
+ .filter(value => !Number.isNaN(value))
100
+ }
101
+
102
+ /**
103
+ * Which side won, or null while that is not yet decided.
104
+ *
105
+ * An explicit `winner` overrides the score, which a walkover or a
106
+ * retirement does not show. Otherwise: most sets won.
107
+ *
108
+ * @returns {string|null}
109
+ */
110
+ winnerSide() {
111
+ const stated = String(this.getConfig().winner || '').trim()
112
+
113
+ if (stated === 'home' || stated === 'guest') {
114
+ return stated
115
+ }
116
+
117
+ const home = this.scoreOf('home')
118
+ const guest = this.scoreOf('guest')
119
+
120
+ if (!home.length || home.length !== guest.length) {
121
+ return null
122
+ }
123
+
124
+ let homeSets = 0
125
+ let guestSets = 0
126
+
127
+ for (const [index, value] of home.entries()) {
128
+ if (value > guest[index]) {
129
+ homeSets += 1
130
+ } else if (value < guest[index]) {
131
+ guestSets += 1
132
+ }
133
+ }
134
+
135
+ if (homeSets === guestSets) {
136
+ return null
137
+ }
138
+
139
+ return homeSets > guestSets ? 'home' : 'guest'
140
+ }
141
+
142
+ /**
143
+ * What the match is doing, in one word.
144
+ *
145
+ * @returns {string}
146
+ */
147
+ stateLabel() {
148
+ const cfg = this.getConfig()
149
+ const status = String(cfg.status || '').trim()
150
+
151
+ const named = {
152
+ walkover: 'Walkover',
153
+ retired: 'Retired',
154
+ no_show: 'No show',
155
+ cancelled: 'Cancelled',
156
+ }
157
+
158
+ if (named[status]) {
159
+ return named[status]
160
+ }
161
+
162
+ // hasAttribute, not the config: an absent attribute comes back as
163
+ // undefined rather than the declared null.
164
+ if (this.hasAttribute('ended')) {
165
+ return 'Final'
166
+ }
167
+
168
+ // A match that has started is a match being played on a court, so
169
+ // the two say the same thing.
170
+ return this.hasAttribute('started') || this.hasAttribute('on-court')
171
+ ? 'On court'
172
+ : ''
173
+ }
174
+
175
+ /**
176
+ * When the match went on, and how long it took once it is over.
177
+ *
178
+ * A match still to be played says nothing: its slot is already the
179
+ * heading it is filed under. One being played says when it started,
180
+ * which is what someone arriving late is asking. A finished one says
181
+ * how long it took as well, because that is the number a director
182
+ * reads a day by.
183
+ *
184
+ * @returns {string}
185
+ */
186
+ timeLabel() {
187
+ const cfg = this.getConfig()
188
+ const at = String(cfg['start-time'] || '').trim()
189
+
190
+ if (!this.hasAttribute('started') && !this.hasAttribute('ended')) {
191
+ return ''
192
+ }
193
+
194
+ const took = this.durationLabel()
195
+
196
+ return [at, took].filter(Boolean).join(' / ')
197
+ }
198
+
199
+ /**
200
+ * @returns {string}
201
+ */
202
+ durationLabel() {
203
+ if (!this.hasAttribute('ended')) {
204
+ return ''
205
+ }
206
+
207
+ const minutes = Number(String(this.getConfig().duration || '').trim())
208
+
209
+ if (!Number.isFinite(minutes) || minutes <= 0) {
210
+ return ''
211
+ }
212
+
213
+ if (minutes < 60) {
214
+ return `${minutes} mins`
215
+ }
216
+
217
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
218
+ }
219
+
220
+ render() {
221
+ if (!this.hasRenderedShape()) {
222
+ this.appendChild(this.renderFromTemplate())
223
+ }
224
+
225
+ const cfg = this.getConfig()
226
+
227
+ this.setText('[tennis-match-court]', String(cfg.court || ''))
228
+ this.setText('[tennis-match-badge]', String(cfg.stage || ''))
229
+ this.setText('[tennis-match-division]', String(cfg.division || ''))
230
+
231
+ const hue = String(cfg.hue || '').trim()
232
+
233
+ if (hue === '') {
234
+ this.style.removeProperty('--division-hue')
235
+ } else {
236
+ this.style.setProperty('--division-hue', hue)
237
+ }
238
+
239
+ const scored = this.scoreOf('home').length > 0
240
+
241
+ const scores = { home: this.scoreOf('home'), guest: this.scoreOf('guest') }
242
+
243
+ for (const side of ['home', 'guest']) {
244
+ this.setScores(
245
+ `[tennis-match-score][data-for="${side}"]`,
246
+ scores[side],
247
+ scores[side === 'home' ? 'guest' : 'home']
248
+ )
249
+ }
250
+
251
+ this.setText('[tennis-match-versus]', 'vs')
252
+ this.setText('[tennis-match-state]', this.stateLabel())
253
+ this.setText('[tennis-match-time]', this.timeLabel())
254
+
255
+ const winner = this.winnerSide()
256
+
257
+ for (const side of ['home', 'guest']) {
258
+ this.sideElement(side)?.toggleAttribute('won', side === winner)
259
+ }
260
+
261
+ this.toggleAttribute('scored', scored)
262
+ this.title = this.summary()
263
+ }
264
+
265
+ /**
266
+ * The card in one line, for a tooltip. The state is spelled out
267
+ * because the badge only says it with a colour.
268
+ *
269
+ * @returns {string}
270
+ */
271
+ summary() {
272
+ const cfg = this.getConfig()
273
+
274
+ return [cfg.stage, cfg.division, cfg.court, this.timeLabel(), this.stateLabel().toLowerCase()]
275
+ .map(part => String(part || '').trim())
276
+ .filter(Boolean)
277
+ .join(', ')
278
+ }
279
+
280
+ /**
281
+ * A side's score, a set to a line.
282
+ *
283
+ * Three sets written as "7 6 10" read as one number with three
284
+ * digits in it. One element each and they read as three sets, which
285
+ * is what the host's CSS then stacks.
286
+ *
287
+ * Reconciled rather than replaced: a morph's detached tree never
288
+ * rebuilds children that were thrown away, so the sets that are
289
+ * already there are rewritten and only the difference is added or
290
+ * removed.
291
+ *
292
+ * Each set is marked with the side that took it, not the side that
293
+ * took the match: a team losing 2-1 still won a set, and the score
294
+ * that won it is theirs to have in bold.
295
+ *
296
+ * @param {string} selector
297
+ * @param {Array<number>} values
298
+ * @param {Array<number>} against - the other side, set for set
299
+ */
300
+ setScores(selector, values, against = []) {
301
+ const element = this.querySelector(selector)
302
+
303
+ if (!element) {
304
+ return
305
+ }
306
+
307
+ const cells = [...element.querySelectorAll(':scope > [tennis-match-set]')]
308
+
309
+ values.forEach((value, index) => {
310
+ const cell = cells[index] ?? element.appendChild(this.setCell())
311
+ const said = String(value)
312
+
313
+ if (cell.firstChild && cell.firstChild.nodeType === 3) {
314
+ if (cell.firstChild.nodeValue !== said) {
315
+ cell.firstChild.nodeValue = said
316
+ }
317
+ } else {
318
+ cell.prepend(document.createTextNode(said))
319
+ }
320
+
321
+ const other = against[index]
322
+
323
+ cell.toggleAttribute('won', Number.isFinite(other) && value > other)
324
+ })
325
+
326
+ for (const spare of cells.slice(values.length)) {
327
+ spare.remove()
328
+ }
329
+ }
330
+
331
+ /**
332
+ * @returns {HTMLElement}
333
+ */
334
+ setCell() {
335
+ const cell = document.createElement('span')
336
+
337
+ cell.setAttribute('tennis-match-set', '')
338
+
339
+ return cell
340
+ }
341
+
342
+ /**
343
+ * Rewrite an element's text without touching its element children.
344
+ *
345
+ * textContent would replace every child, which in a morph's detached
346
+ * tree is never rebuilt.
347
+ *
348
+ * @param {string} selector
349
+ * @param {string} value
350
+ */
351
+ setText(selector, value) {
352
+ const element = this.querySelector(selector)
353
+
354
+ if (!element) {
355
+ return
356
+ }
357
+
358
+ if (element.firstChild && element.firstChild.nodeType === 3) {
359
+ if (element.firstChild.nodeValue !== value) {
360
+ element.firstChild.nodeValue = value
361
+ }
362
+
363
+ return
364
+ }
365
+
366
+ element.prepend(document.createTextNode(value))
367
+ }
368
+ }
@@ -0,0 +1,101 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { HTMLElementSugar, withConnectedSucu } from '@skirbi/sugar'
6
+ import { setNameText } from '../flags.mjs'
7
+
8
+ /**
9
+ * <tennis-participant> - one side of a match.
10
+ *
11
+ * A participant is the competition entry, not a person: one player,
12
+ * a doubles pair, or a squad under its own name. A named team shows
13
+ * its name with its members under it, everything else the members
14
+ * alone.
15
+ *
16
+ * @example
17
+ * <tennis-participant side="home" score="7,6,10"
18
+ * label="🇦🇼 Alice, 🇳🇱 Bob"></tennis-participant>
19
+ *
20
+ * @example
21
+ * <tennis-participant side="guest" team-name="Team Awesome"
22
+ * label="🇦🇼 Carl, 🇳🇱 Dana"></tennis-participant>
23
+ *
24
+ * `side` and `score` are read by <tennis-match>, not by this one.
25
+ * <tennis-match> accepts any child carrying `side`, so a host with its
26
+ * own participant element does not need this.
27
+ */
28
+ export class TennisParticipantElement extends withConnectedSucu(HTMLElementSugar) {
29
+ static tag = 'tennis-participant'
30
+
31
+ static exampleHTML = '<tennis-participant label="🇦🇼 Alice"></tennis-participant>'
32
+ static exampleRenderedHTML = '<span tennis-participant-label>🇦🇼 Alice</span>'
33
+
34
+ static renderGuardSelector = ':scope > [tennis-participant-root]'
35
+
36
+ static attributeDefs = {
37
+ label: { default: '' },
38
+ 'team-name': { default: '' },
39
+ 'flag-svg': { default: 'windows' },
40
+ }
41
+
42
+ connectedCallbackSugar() {
43
+ this.render()
44
+ }
45
+
46
+ updatedAttributeSugar(name, oldValue, newValue) {
47
+ if (oldValue !== newValue) {
48
+ this.render()
49
+ }
50
+ }
51
+
52
+ morphedCallbackSugar() {
53
+ this.render()
54
+ }
55
+
56
+ render() {
57
+ const cfg = this.getConfig()
58
+ const teamName = String(cfg['team-name'] || '').trim()
59
+ const label = String(cfg.label || '').trim()
60
+ const mode = String(cfg['flag-svg'] || 'windows')
61
+
62
+ const root = document.createElement('span')
63
+
64
+ root.setAttribute('tennis-participant-root', '')
65
+
66
+ if (teamName) {
67
+ const name = document.createElement('span')
68
+
69
+ name.setAttribute('tennis-participant-team-name', '')
70
+ setNameText(name, teamName, mode)
71
+ root.append(name)
72
+ }
73
+
74
+ const labelElement = document.createElement('span')
75
+
76
+ labelElement.setAttribute('tennis-participant-label', '')
77
+
78
+ const players = label.split(',').map(part => part.trim()).filter(Boolean)
79
+
80
+ if (players.length > 1) {
81
+ labelElement.setAttribute('data-player-count', String(players.length))
82
+
83
+ for (const player of players) {
84
+ const playerElement = document.createElement('span')
85
+
86
+ playerElement.setAttribute('tennis-participant-player', '')
87
+ setNameText(playerElement, player, mode)
88
+ labelElement.append(playerElement)
89
+ }
90
+ } else {
91
+ setNameText(labelElement, label, mode)
92
+ }
93
+
94
+ root.append(labelElement)
95
+
96
+ const fragment = document.createDocumentFragment()
97
+
98
+ fragment.append(root)
99
+ this.replaceChildrenSugarSafe(fragment)
100
+ }
101
+ }