@opndev/tennis-match-center 0.0.2 → 0.0.3
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 +32 -1
- package/lib/components/day-grid.mjs +19 -7
- package/lib/components/gameday-board.mjs +111 -13
- package/lib/components/gameday-card.mjs +31 -0
- package/lib/components/match.mjs +17 -0
- package/lib/components/roster-board.mjs +42 -2
- package/lib/index.mjs +1 -1
- package/package.json +1 -1
package/Changes
CHANGED
|
@@ -1,4 +1,35 @@
|
|
|
1
|
-
Revision history for@opndev/tennis-match-center
|
|
1
|
+
Revision history for @opndev/tennis-match-center
|
|
2
|
+
|
|
3
|
+
0.0.3 2026-10-08 15:31:37Z
|
|
4
|
+
|
|
5
|
+
* Auto score on the gameday desk. Enter in a score box fills the other
|
|
6
|
+
side with 7 when one side is under 7 and the other is empty, then
|
|
7
|
+
ends the match. Leaving a score box, by Tab or a click, only
|
|
8
|
+
prefills the 7 and leaves the match open, so a click on the end
|
|
9
|
+
button is not lost. With both boxes filled, Enter just ends the
|
|
10
|
+
match.
|
|
11
|
+
|
|
12
|
+
* The next-on-court dialog lists the match planned for the court that
|
|
13
|
+
came free first and picks it by default, with the longest wait as
|
|
14
|
+
the alternative below it. Enter in the dialog presses Play, and a
|
|
15
|
+
held Enter that repeats into the dialog is ignored.
|
|
16
|
+
|
|
17
|
+
* The match card says where a match is next to its court: Not before a
|
|
18
|
+
time, Go to court, On court or Final. A host states it with the new
|
|
19
|
+
`state` and `not-before` attributes, because next up depends on the
|
|
20
|
+
other matches on the court. Without a state the card reads started
|
|
21
|
+
and ended as before, and a walkover or retirement still says what
|
|
22
|
+
it was.
|
|
23
|
+
|
|
24
|
+
* The roster board says when each group starts: the first planned
|
|
25
|
+
match of each day it plays, from a `starts` list the roster data
|
|
26
|
+
can carry, such as `Mon 10 Nov, 13:00 / Tue 11 Nov, 09:30`. A group
|
|
27
|
+
nothing is planned for says nothing.
|
|
28
|
+
|
|
29
|
+
* The day grid sizes its hours and cells by the CSS variable
|
|
30
|
+
--planning-scale. A host can make a day more compact or roomier
|
|
31
|
+
with one variable, and a day already drawn follows it without a
|
|
32
|
+
redraw. The default scale is 1, as before.
|
|
2
33
|
|
|
3
34
|
0.0.2 2026-09-21 03:14:21Z
|
|
4
35
|
|
|
@@ -72,7 +72,7 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
72
72
|
this.dataset.day = date
|
|
73
73
|
this.setAttribute('date', date)
|
|
74
74
|
this.toggleAttribute('compact', compact)
|
|
75
|
-
this.style.setProperty('--planning-day-height',
|
|
75
|
+
this.style.setProperty('--planning-day-height', this.lengthFor(range.end - range.start))
|
|
76
76
|
|
|
77
77
|
this.replaceChildren(
|
|
78
78
|
this.timeColumn(rows, range),
|
|
@@ -94,14 +94,14 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
94
94
|
column.appendChild(document.createElement('planning-times-head'))
|
|
95
95
|
|
|
96
96
|
const body = document.createElement('planning-rows')
|
|
97
|
-
body.style.setProperty('--planning-day-height',
|
|
97
|
+
body.style.setProperty('--planning-day-height', this.lengthFor(range.end - range.start))
|
|
98
98
|
|
|
99
99
|
for (const slot of rows) {
|
|
100
100
|
const time = document.createElement('planning-time')
|
|
101
101
|
|
|
102
102
|
time.setAttribute('start', slot.key)
|
|
103
|
-
time.style.top =
|
|
104
|
-
time.style.height =
|
|
103
|
+
time.style.top = this.lengthFor(slot.start_minutes - range.start)
|
|
104
|
+
time.style.height = this.lengthFor(slot.end_minutes - slot.start_minutes)
|
|
105
105
|
time.textContent = slot.label ?? slot.key
|
|
106
106
|
|
|
107
107
|
column.appendChild(body).appendChild(time)
|
|
@@ -129,7 +129,7 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
129
129
|
column.appendChild(heading)
|
|
130
130
|
|
|
131
131
|
const body = document.createElement('planning-rows')
|
|
132
|
-
body.style.setProperty('--planning-day-height',
|
|
132
|
+
body.style.setProperty('--planning-day-height', this.lengthFor(range.end - range.start))
|
|
133
133
|
|
|
134
134
|
// A row's own span is only ever the finest clock running that day
|
|
135
135
|
// - a real match longer than that (Team Cup's 90 minutes among
|
|
@@ -172,8 +172,8 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
172
172
|
const minutes = cell.minutes ?? (slot.end_minutes - slot.start_minutes)
|
|
173
173
|
|
|
174
174
|
element.setAttribute('start', slot.key)
|
|
175
|
-
element.style.top =
|
|
176
|
-
element.style.height =
|
|
175
|
+
element.style.top = this.lengthFor(slot.start_minutes - range.start)
|
|
176
|
+
element.style.height = this.lengthFor(minutes)
|
|
177
177
|
|
|
178
178
|
if (slot.start_minutes != null) {
|
|
179
179
|
element.setAttribute('duration', minutes)
|
|
@@ -235,6 +235,18 @@ export class TennisDayGridElement extends HTMLElementSugar {
|
|
|
235
235
|
return minutes * (this.scale ?? PX_PER_MINUTE)
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The same size as a CSS length that follows --planning-scale, so a
|
|
240
|
+
* host can make the whole day more compact (or roomier) with one
|
|
241
|
+
* variable, and a day already drawn follows it without a redraw.
|
|
242
|
+
*
|
|
243
|
+
* @param {number} minutes
|
|
244
|
+
* @returns {string}
|
|
245
|
+
*/
|
|
246
|
+
lengthFor(minutes) {
|
|
247
|
+
return `calc(${this.pxFor(minutes)}px * var(--planning-scale, 1))`
|
|
248
|
+
}
|
|
249
|
+
|
|
238
250
|
/**
|
|
239
251
|
* Shows what is being dragged and where it would land. The boards
|
|
240
252
|
* decide what a drag means; showing one is the same job on both.
|
|
@@ -138,12 +138,12 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
138
138
|
confirm-label="Play"
|
|
139
139
|
show-confirm="true">
|
|
140
140
|
<p data-next-said></p>
|
|
141
|
-
<p data-waiting-label></p>
|
|
142
|
-
<p data-waiting-head></p>
|
|
143
|
-
<p data-waiting-match></p>
|
|
144
141
|
<p data-queued-label></p>
|
|
145
142
|
<p data-queued-head></p>
|
|
146
143
|
<p data-queued-match></p>
|
|
144
|
+
<p data-waiting-label></p>
|
|
145
|
+
<p data-waiting-head></p>
|
|
146
|
+
<p data-waiting-match></p>
|
|
147
147
|
</semtic-modal>
|
|
148
148
|
<semtic-modal data-busy-modal hidden
|
|
149
149
|
header="That court is taken"
|
|
@@ -200,6 +200,14 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
200
200
|
this.recordScore(event.detail)
|
|
201
201
|
})
|
|
202
202
|
|
|
203
|
+
this.addEventListener('gameday-card:enter', event => {
|
|
204
|
+
this.autoScore(event.detail)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
this.addEventListener('gameday-card:leave', event => {
|
|
208
|
+
this.autoPrefill(event.detail)
|
|
209
|
+
})
|
|
210
|
+
|
|
203
211
|
this.addEventListener('gameday-card:court', event => {
|
|
204
212
|
this.courtDialog.open(event.detail.match)
|
|
205
213
|
})
|
|
@@ -285,10 +293,27 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
285
293
|
|
|
286
294
|
this.nextDialog = new GamedayDialog(this.$('[data-next-modal]'), {
|
|
287
295
|
fill: match => this.fillNext(match),
|
|
288
|
-
focus: modal => modal.querySelector('[data-next-modal] input')
|
|
296
|
+
focus: modal => modal.querySelector('[data-next-modal] input:checked')
|
|
297
|
+
?? modal.querySelector('[data-next-modal] input')
|
|
298
|
+
?? modal.querySelector('[semtic-modal-cancel]'),
|
|
289
299
|
confirmed: match => this.playPicked(this.effectiveCourtId(match)),
|
|
290
300
|
})
|
|
291
301
|
|
|
302
|
+
// Enter is Play. A held Enter repeats into the dialog that the first
|
|
303
|
+
// one opened, so only a fresh press counts.
|
|
304
|
+
this.$('[data-next-modal]').addEventListener('keydown', event => {
|
|
305
|
+
const confirm = this.$('[data-next-modal] [semtic-modal-confirm]')
|
|
306
|
+
|
|
307
|
+
if (event.key !== 'Enter' || event.repeat || event.target.closest('button')) {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (confirm && !confirm.hasAttribute('disabled')) {
|
|
312
|
+
event.preventDefault()
|
|
313
|
+
confirm.click()
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
292
317
|
this.busyDialog = new GamedayDialog(this.$('[data-busy-modal]'), {
|
|
293
318
|
fill: match => this.fillBusy(match),
|
|
294
319
|
focus: modal => modal.querySelector('[data-busy-select] select')
|
|
@@ -871,6 +896,78 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
871
896
|
match.sets[index][`${side}_games`] = value === '' ? null : Number(value)
|
|
872
897
|
}
|
|
873
898
|
|
|
899
|
+
/**
|
|
900
|
+
* One side has a score under seven and the other is empty: the other
|
|
901
|
+
* side gets seven.
|
|
902
|
+
*
|
|
903
|
+
* @param {object} match
|
|
904
|
+
* @param {number} set
|
|
905
|
+
* @returns {?string} the side that was filled in, if any
|
|
906
|
+
*/
|
|
907
|
+
autoFill(match, set) {
|
|
908
|
+
const row = match.sets[set - 1]
|
|
909
|
+
|
|
910
|
+
if (!row) {
|
|
911
|
+
return null
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const home = row.home_games ?? null
|
|
915
|
+
const visitor = row.visitor_games ?? null
|
|
916
|
+
|
|
917
|
+
if (home !== null && visitor === null && home < 7) {
|
|
918
|
+
row.visitor_games = 7
|
|
919
|
+
|
|
920
|
+
return 'visitor'
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (visitor !== null && home === null && visitor < 7) {
|
|
924
|
+
row.home_games = 7
|
|
925
|
+
|
|
926
|
+
return 'home'
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return null
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Enter in a score box ends the match, after the auto score.
|
|
934
|
+
*
|
|
935
|
+
* @param {{match: object, set: number}} entered
|
|
936
|
+
*/
|
|
937
|
+
async autoScore({ match, set }) {
|
|
938
|
+
if (!match || this.segmentOf(match) === 'finished') {
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
this.autoFill(match, set)
|
|
943
|
+
|
|
944
|
+
if (this.hasScore(match)) {
|
|
945
|
+
await this.toggleFinished(match)
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Focus left the score boxes: prefill the seven and leave the
|
|
951
|
+
* ending to the desk. The box is written directly, since a redraw
|
|
952
|
+
* under the pointer would swallow a click on the end button.
|
|
953
|
+
*
|
|
954
|
+
* @param {{match: object, set: number}} left
|
|
955
|
+
*/
|
|
956
|
+
autoPrefill({ match, set }) {
|
|
957
|
+
if (!match || this.segmentOf(match) === 'finished') {
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const side = this.autoFill(match, set)
|
|
962
|
+
const box = side && this.$(
|
|
963
|
+
`[data-match-id="${match.id}"] [data-side="${side}"] input[data-set="${set}"]`
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
if (box) {
|
|
967
|
+
box.value = '7'
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
874
971
|
/**
|
|
875
972
|
* @param {object} match
|
|
876
973
|
*/
|
|
@@ -1541,15 +1638,15 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
1541
1638
|
// saying it here.
|
|
1542
1639
|
const longest = said.longest
|
|
1543
1640
|
|
|
1544
|
-
this.sayMatchBlock('waiting', longest, 'Longest waiting match')
|
|
1545
|
-
|
|
1546
1641
|
// One match, said once. When the longest wait is this court's own
|
|
1547
1642
|
// queue, a second block would be the same two names again.
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1643
|
+
const queued = said.next && said.next !== longest ? said.next : null
|
|
1644
|
+
|
|
1645
|
+
this.sayMatchBlock('queued', queued, `Next planned match on ${court}`, true)
|
|
1646
|
+
|
|
1647
|
+
// What was planned for this court is the default. The longest wait
|
|
1648
|
+
// is the alternative, or the only choice when nothing is queued here.
|
|
1649
|
+
this.sayMatchBlock('waiting', longest, 'Longest waiting match', !queued)
|
|
1553
1650
|
|
|
1554
1651
|
// Nothing is waiting anywhere, so there is nothing to put on.
|
|
1555
1652
|
this.$('[data-next-modal] [semtic-modal-confirm]')
|
|
@@ -1600,8 +1697,9 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
1600
1697
|
* @param {string} role - 'waiting' or 'queued'
|
|
1601
1698
|
* @param {object|null} match
|
|
1602
1699
|
* @param {string} label
|
|
1700
|
+
* @param {boolean} checked - whether it is the default pick
|
|
1603
1701
|
*/
|
|
1604
|
-
sayMatchBlock(role, match, label) {
|
|
1702
|
+
sayMatchBlock(role, match, label, checked) {
|
|
1605
1703
|
for (const part of ['label', 'head', 'match']) {
|
|
1606
1704
|
this.$(`[data-${role}-${part}]`).hidden = !match
|
|
1607
1705
|
}
|
|
@@ -1617,7 +1715,7 @@ export class TennisGamedayBoardElement extends HTMLElementSugar {
|
|
|
1617
1715
|
'next-play',
|
|
1618
1716
|
String(match.id),
|
|
1619
1717
|
label,
|
|
1620
|
-
|
|
1718
|
+
checked,
|
|
1621
1719
|
'right'
|
|
1622
1720
|
))
|
|
1623
1721
|
|
|
@@ -38,6 +38,8 @@ import { scoreField, teamCell, textTag } from '../gameday-parts.mjs'
|
|
|
38
38
|
* Fired, all bubbling, all carrying `{ match }`:
|
|
39
39
|
*
|
|
40
40
|
* gameday-card:score a score box was typed in
|
|
41
|
+
* gameday-card:enter Enter was pressed in a score box
|
|
42
|
+
* gameday-card:leave focus left the score boxes
|
|
41
43
|
* gameday-card:action end the match, or reopen it
|
|
42
44
|
* gameday-card:start start the match, or take that back
|
|
43
45
|
* gameday-card:court the court was asked about
|
|
@@ -81,6 +83,35 @@ export class TennisGamedayCardElement extends HTMLElementSugar {
|
|
|
81
83
|
}
|
|
82
84
|
})
|
|
83
85
|
|
|
86
|
+
this.addEventListener('keydown', event => {
|
|
87
|
+
const input = event.target.closest?.('[data-scores] input')
|
|
88
|
+
|
|
89
|
+
if (input && event.key === 'Enter') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
this.say('enter', {
|
|
92
|
+
set: Number(input.dataset.set),
|
|
93
|
+
side: input.closest('[data-scores]').dataset.side,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
this.addEventListener('focusout', event => {
|
|
99
|
+
const input = event.target.closest?.('[data-scores] input')
|
|
100
|
+
const next = event.relatedTarget
|
|
101
|
+
|
|
102
|
+
if (!input) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!next && this.ownerDocument.hasFocus?.() === false) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.say('leave', {
|
|
111
|
+
set: Number(input.dataset.set),
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
84
115
|
this.addEventListener('click', event => {
|
|
85
116
|
const pressed = event.target.closest(
|
|
86
117
|
'[data-court-open], [data-start], [data-action]'
|
package/lib/components/match.mjs
CHANGED
|
@@ -39,6 +39,8 @@ export class TennisMatchElement extends withConnectedSucu(HTMLElementSugar) {
|
|
|
39
39
|
ended: { default: null },
|
|
40
40
|
'on-court': { default: null },
|
|
41
41
|
status: { default: '' },
|
|
42
|
+
state: { default: '' },
|
|
43
|
+
'not-before': { default: '' },
|
|
42
44
|
winner: { default: '' },
|
|
43
45
|
hue: { default: '' },
|
|
44
46
|
}
|
|
@@ -159,6 +161,21 @@ export class TennisMatchElement extends withConnectedSucu(HTMLElementSugar) {
|
|
|
159
161
|
return named[status]
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
// Said by the host when it knows, because next up depends on the
|
|
165
|
+
// other matches on the court and a card cannot see those.
|
|
166
|
+
const state = String(cfg.state || '').trim()
|
|
167
|
+
|
|
168
|
+
if (state) {
|
|
169
|
+
const notBefore = String(cfg['not-before'] || '').trim()
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
final: 'Final',
|
|
173
|
+
on_court: 'On court',
|
|
174
|
+
next_up: 'Go to court',
|
|
175
|
+
pending: notBefore ? `Not before ${notBefore}` : '',
|
|
176
|
+
}[state] ?? ''
|
|
177
|
+
}
|
|
178
|
+
|
|
162
179
|
// hasAttribute, not the config: an absent attribute comes back as
|
|
163
180
|
// undefined rather than the declared null.
|
|
164
181
|
if (this.hasAttribute('ended')) {
|
|
@@ -5,6 +5,37 @@
|
|
|
5
5
|
import { HTMLElementSugar } from '@skirbi/sugar'
|
|
6
6
|
import { seedGroups, groupName } from '../roster-seeding.mjs'
|
|
7
7
|
|
|
8
|
+
const DAY = new Intl.DateTimeFormat('en-GB', {
|
|
9
|
+
weekday: 'short',
|
|
10
|
+
day: 'numeric',
|
|
11
|
+
month: 'short',
|
|
12
|
+
timeZone: 'UTC',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The day an ISO timestamp falls on, read off the string so the event's
|
|
17
|
+
* own clock is what is said, not the browser's.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} iso
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function dayOf(iso) {
|
|
23
|
+
return DAY.format(new Date(`${iso.slice(0, 10)}T12:00:00Z`))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* When a group starts playing, as far as the planning knows it: the
|
|
28
|
+
* first match of each day it plays.
|
|
29
|
+
*
|
|
30
|
+
* @param {Array<string>} starts - ISO timestamps, one per day
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function startsLine(starts) {
|
|
34
|
+
return starts
|
|
35
|
+
.map(iso => `${dayOf(iso)}, ${iso.slice(11, 16)}`)
|
|
36
|
+
.join(' / ')
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
/**
|
|
9
40
|
* <tennis-roster-board api="..." save-api="...">
|
|
10
41
|
*
|
|
@@ -173,11 +204,11 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
|
|
|
173
204
|
.map(id => this.saved.teams.find(team => team.id === id))
|
|
174
205
|
.filter(Boolean)
|
|
175
206
|
|
|
176
|
-
columns.appendChild(this.columnFor(group.name, teams))
|
|
207
|
+
columns.appendChild(this.columnFor(group.name, teams, group.starts))
|
|
177
208
|
}
|
|
178
209
|
}
|
|
179
210
|
|
|
180
|
-
columnFor(currentGroupName, teams) {
|
|
211
|
+
columnFor(currentGroupName, teams, starts = []) {
|
|
181
212
|
const column = document.createElement('div')
|
|
182
213
|
|
|
183
214
|
column.dataset.group = currentGroupName ?? ''
|
|
@@ -212,6 +243,15 @@ export class TennisRosterBoardElement extends HTMLElementSugar {
|
|
|
212
243
|
count.textContent = String(teams.length)
|
|
213
244
|
heading.appendChild(count)
|
|
214
245
|
|
|
246
|
+
if (starts?.length) {
|
|
247
|
+
const when = document.createElement('p')
|
|
248
|
+
|
|
249
|
+
when.dataset.planned = ''
|
|
250
|
+
when.title = 'First match of the group, per day'
|
|
251
|
+
when.textContent = startsLine(starts)
|
|
252
|
+
column.appendChild(when)
|
|
253
|
+
}
|
|
254
|
+
|
|
215
255
|
const list = document.createElement('div')
|
|
216
256
|
list.dataset.list = ''
|
|
217
257
|
column.appendChild(list)
|
package/lib/index.mjs
CHANGED
package/package.json
CHANGED