@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.
- package/Changes +26 -0
- package/lib/auto-planner.mjs +5 -32
- package/lib/components/day-grid.mjs +288 -0
- package/lib/components/gameday-board.mjs +1691 -0
- package/lib/components/gameday-card.mjs +325 -0
- package/lib/components/gameday-list.mjs +199 -0
- package/lib/components/match.mjs +368 -0
- package/lib/components/participant.mjs +101 -0
- package/lib/components/planning-board.mjs +914 -131
- package/lib/components/schedule-board.mjs +219 -50
- package/lib/day-rows.mjs +19 -3
- package/lib/gameday-dialog.mjs +93 -0
- package/lib/gameday-facts.mjs +305 -0
- package/lib/gameday-next.mjs +164 -0
- package/lib/gameday-parts.mjs +112 -0
- package/lib/index.mjs +1 -3
- package/lib/knockout-draw.mjs +4 -0
- package/lib/knockout-planner.mjs +99 -5
- package/lib/lighting.mjs +50 -4
- package/lib/plan-placement.mjs +54 -3
- package/lib/planning-day.mjs +15 -9
- package/lib/webcomponents.mjs +12 -0
- package/package.json +6 -1
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
|
@@ -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
|
+
}
|