@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,305 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the desk knows about a match, worked out from the day and the
|
|
7
|
+
* shell rather than from the DOM.
|
|
8
|
+
*
|
|
9
|
+
* Here rather than on the board because a card asks the same questions
|
|
10
|
+
* the board does, and two components answering them separately is how
|
|
11
|
+
* a line and the dialog about that line end up disagreeing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Which segment a match belongs in. Anything played is finished,
|
|
16
|
+
* walkovers included.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} match
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function segmentOf(match) {
|
|
22
|
+
if (match.completed_at || match.status === 'completed') {
|
|
23
|
+
return 'finished'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return match.started_at || match.status === 'live' ? 'started' : 'pending'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} match
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function startTimeOf(match) {
|
|
34
|
+
const at = match.scheduled_at
|
|
35
|
+
|
|
36
|
+
return at ? at.slice(11, 16) : 'Unscheduled'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The group or the round, whichever a match belongs to. Carried on its
|
|
41
|
+
* own so it can be the badge the public card gives it.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} day
|
|
44
|
+
* @param {object} match
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function stageOf(day, match) {
|
|
48
|
+
const group = day.groups[match.group_id]
|
|
49
|
+
|
|
50
|
+
return match.round === 'group'
|
|
51
|
+
? (group ? `Group ${group}` : 'Group')
|
|
52
|
+
: match.round
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} shell
|
|
57
|
+
* @param {object} match
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function divisionOf(shell, match) {
|
|
61
|
+
return shell.divisions[match.division_id]?.name ?? ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} shell
|
|
66
|
+
* @param {object} match
|
|
67
|
+
* @returns {number|null|undefined}
|
|
68
|
+
*/
|
|
69
|
+
export function hueOf(shell, match) {
|
|
70
|
+
return shell.divisions[match.division_id]?.hue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A side in one string, for the places that cannot carry two: a
|
|
75
|
+
* tooltip, a modal, the question of who won.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} day
|
|
78
|
+
* @param {number|null} teamId
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function teamName(day, teamId) {
|
|
82
|
+
const team = day.teams[teamId]
|
|
83
|
+
|
|
84
|
+
if (!team) {
|
|
85
|
+
return 'TBD'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A string, for a day that still sends one.
|
|
89
|
+
if (typeof team === 'string') {
|
|
90
|
+
return team
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [team.name, team.members].filter(Boolean).join(': ') || 'TBD'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {object} shell
|
|
98
|
+
* @param {object} match
|
|
99
|
+
* @returns {object|null}
|
|
100
|
+
*/
|
|
101
|
+
export function formatFor(shell, match) {
|
|
102
|
+
const division = shell.divisions[match.division_id]
|
|
103
|
+
|
|
104
|
+
return division?.scoring_format_id
|
|
105
|
+
? shell.scoring_formats[division.scoring_format_id] ?? null
|
|
106
|
+
: null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Which court a match is actually on. The desk never rewrites the
|
|
111
|
+
* planned court; the planning board still owns that.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} match
|
|
114
|
+
* @returns {number|null}
|
|
115
|
+
*/
|
|
116
|
+
export function effectiveCourtId(match) {
|
|
117
|
+
return match.actual_court_id ?? match.court_id
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {object} match
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function movedCourt(match) {
|
|
125
|
+
return match.actual_court_id != null
|
|
126
|
+
&& match.actual_court_id !== match.court_id
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The courts with a started, unfinished match on them in `day`.
|
|
131
|
+
* `match` is not counted against its own court.
|
|
132
|
+
*
|
|
133
|
+
* @param {object|null} day
|
|
134
|
+
* @param {object|null} match
|
|
135
|
+
* @returns {Set<number|null>}
|
|
136
|
+
*/
|
|
137
|
+
export function busyCourtIds(day, match) {
|
|
138
|
+
return new Set(
|
|
139
|
+
(day?.matches ?? [])
|
|
140
|
+
.filter(other => other.id !== match?.id && segmentOf(other) === 'started')
|
|
141
|
+
.map(other => effectiveCourtId(other))
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Every court, the busy ones marked.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} shell
|
|
149
|
+
* @param {object|null} day
|
|
150
|
+
* @param {object|null} match
|
|
151
|
+
* @returns {Array<{value: string, label: string}>}
|
|
152
|
+
*/
|
|
153
|
+
export function courtOptions(shell, day = null, match = null) {
|
|
154
|
+
const busy = busyCourtIds(day, match)
|
|
155
|
+
|
|
156
|
+
return shell.courts.map(court => ({
|
|
157
|
+
value: String(court.id),
|
|
158
|
+
label: busy.has(court.id) ? `${court.name} (on court)` : court.name,
|
|
159
|
+
}))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Active courts with no started, unfinished match on them in `day`.
|
|
164
|
+
* `match` itself is not counted against its own court.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} shell
|
|
167
|
+
* @param {object|null} day
|
|
168
|
+
* @param {object|null} match
|
|
169
|
+
* @returns {Array<{value: string, label: string}>}
|
|
170
|
+
*/
|
|
171
|
+
export function freeCourtOptions(shell, day, match) {
|
|
172
|
+
const busy = busyCourtIds(day, match)
|
|
173
|
+
|
|
174
|
+
return shell.courts
|
|
175
|
+
.filter(court => court.active !== false && !busy.has(court.id))
|
|
176
|
+
.map(court => ({
|
|
177
|
+
value: String(court.id),
|
|
178
|
+
label: court.name,
|
|
179
|
+
}))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {object} shell
|
|
184
|
+
* @param {number|null} courtId
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
export function courtName(shell, courtId) {
|
|
188
|
+
return shell.courts.find(court => court.id === courtId)?.name ?? 'No court'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* What the match is doing, said in a word.
|
|
193
|
+
*
|
|
194
|
+
* Only the tooltip asks: on the line itself the section it is filed
|
|
195
|
+
* under and the lit court already say it.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} match
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
export function stateOf(match) {
|
|
201
|
+
const named = {
|
|
202
|
+
walkover: 'Walkover',
|
|
203
|
+
retired: 'Retired',
|
|
204
|
+
no_show: 'No show',
|
|
205
|
+
cancelled: 'Cancelled',
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (named[match.status]) {
|
|
209
|
+
return named[match.status]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const segment = segmentOf(match)
|
|
213
|
+
|
|
214
|
+
return segment === 'finished' ? 'Final'
|
|
215
|
+
: segment === 'started' ? 'On court'
|
|
216
|
+
: 'Pending'
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {string|null} iso
|
|
221
|
+
* @returns {string}
|
|
222
|
+
*/
|
|
223
|
+
export function clockOf(iso) {
|
|
224
|
+
return iso ? String(iso).slice(11, 16) : ''
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whole minutes, because nobody reads a match in seconds.
|
|
229
|
+
*
|
|
230
|
+
* @param {object} match
|
|
231
|
+
* @returns {number|null}
|
|
232
|
+
*/
|
|
233
|
+
export function minutesOf(match) {
|
|
234
|
+
if (!match.started_at || !match.completed_at) {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const minutes = Math.round(
|
|
239
|
+
(Date.parse(match.completed_at) - Date.parse(match.started_at)) / 60000
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return Number.isFinite(minutes) && minutes > 0 ? minutes : null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {number} minutes
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
export function saidAsTime(minutes) {
|
|
250
|
+
if (!Number.isFinite(minutes)) {
|
|
251
|
+
return ''
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return minutes < 60
|
|
255
|
+
? `${minutes} mins`
|
|
256
|
+
: `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* When the match actually went on, and how long it took once it is
|
|
261
|
+
* over.
|
|
262
|
+
*
|
|
263
|
+
* The block it sits under is its slot, not when it started: a day runs
|
|
264
|
+
* late and the difference between the two is the thing an official is
|
|
265
|
+
* trying to see.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} match
|
|
268
|
+
* @returns {string}
|
|
269
|
+
*/
|
|
270
|
+
export function tookOf(match) {
|
|
271
|
+
const at = clockOf(match.started_at)
|
|
272
|
+
|
|
273
|
+
if (!at) {
|
|
274
|
+
return ''
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const minutes = minutesOf(match)
|
|
278
|
+
|
|
279
|
+
return minutes === null ? at : `${at} / ${saidAsTime(minutes)}`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A set counts once both sides are filled in, and they have to differ:
|
|
284
|
+
* 0 against an empty box is a half-typed score, and 0-0 is a set
|
|
285
|
+
* nobody played.
|
|
286
|
+
*
|
|
287
|
+
* @param {object} set
|
|
288
|
+
* @returns {boolean}
|
|
289
|
+
*/
|
|
290
|
+
export function setHasScore(set) {
|
|
291
|
+
return Number.isFinite(set?.home_games)
|
|
292
|
+
&& Number.isFinite(set?.visitor_games)
|
|
293
|
+
&& set.home_games !== set.visitor_games
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Whether a result was actually typed. An emptied box leaves its set
|
|
298
|
+
* behind holding nulls, so the sets being there is not the question.
|
|
299
|
+
*
|
|
300
|
+
* @param {object} match
|
|
301
|
+
* @returns {boolean}
|
|
302
|
+
*/
|
|
303
|
+
export function hasScore(match) {
|
|
304
|
+
return (match.sets ?? []).some(set => setHasScore(set))
|
|
305
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { effectiveCourtId, segmentOf } from './gameday-facts.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What happens on a court once the match on it is over.
|
|
9
|
+
*
|
|
10
|
+
* A court that has just been handed back is the one moment somebody
|
|
11
|
+
* can do something about the day running out of shape, and it is also
|
|
12
|
+
* the moment an official has their head down writing a score. So the
|
|
13
|
+
* desk works it out for them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a match could actually be played right now.
|
|
18
|
+
*
|
|
19
|
+
* A group match always could: both sides are known the moment the
|
|
20
|
+
* draw is made. A knockout match may be waiting on the two matches
|
|
21
|
+
* that feed it, and telling an official a court is free for a
|
|
22
|
+
* quarter-final whose players do not exist yet is telling them
|
|
23
|
+
* nothing.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} match
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function playable(match) {
|
|
29
|
+
return Boolean(match.home_team_id) && Boolean(match.visitor_team_id)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Every match still waiting, longest wait first.
|
|
34
|
+
*
|
|
35
|
+
* Longest waiting, not nearest in the schedule: by the afternoon the
|
|
36
|
+
* day is behind and the queue is what the desk is working through, so
|
|
37
|
+
* the one that should have gone on first is the one to name. A
|
|
38
|
+
* playable match comes before an unplayable one, and within each the
|
|
39
|
+
* earlier slot leads, which a stable sort gives for free.
|
|
40
|
+
*
|
|
41
|
+
* Only matches still waiting. One already under way is a state the
|
|
42
|
+
* desk can see for itself, and a played one is not waiting.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} day
|
|
45
|
+
* @param {object} done - the one that just finished, never its own next
|
|
46
|
+
* @returns {Array<object>}
|
|
47
|
+
*/
|
|
48
|
+
function queue(day, done) {
|
|
49
|
+
return day.matches
|
|
50
|
+
.filter(other => other.id !== done.id)
|
|
51
|
+
.filter(other => other.scheduled_at)
|
|
52
|
+
.filter(other => segmentOf(other) === 'pending')
|
|
53
|
+
.sort((a, b) => a.scheduled_at < b.scheduled_at ? -1 : 1)
|
|
54
|
+
.sort((a, b) => Number(playable(b)) - Number(playable(a)))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The match that has been waiting longest anywhere on the day.
|
|
59
|
+
*
|
|
60
|
+
* Anywhere, not on this court: a court coming free is somewhere to put
|
|
61
|
+
* the match that has been standing around longest, whichever court it
|
|
62
|
+
* was planned for.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} day
|
|
65
|
+
* @param {object} done
|
|
66
|
+
* @returns {object|null}
|
|
67
|
+
*/
|
|
68
|
+
export function longestWaiting(day, done) {
|
|
69
|
+
return queue(day, done)[0] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The match that has been waiting longest on the court that just came
|
|
74
|
+
* free.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} day
|
|
77
|
+
* @param {object} done
|
|
78
|
+
* @returns {object|null}
|
|
79
|
+
*/
|
|
80
|
+
export function nextOnCourt(day, done) {
|
|
81
|
+
const court = effectiveCourtId(done)
|
|
82
|
+
|
|
83
|
+
if (court === null || court === undefined) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return queue(day, done).find(
|
|
88
|
+
other => effectiveCourtId(other) === court
|
|
89
|
+
) ?? null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How long a match takes around here, in minutes.
|
|
94
|
+
*
|
|
95
|
+
* Today's average if today has any matches behind it, the event's
|
|
96
|
+
* otherwise, because the first court to come free on a Saturday has no
|
|
97
|
+
* day to average yet. Null when nothing has been played at all, and
|
|
98
|
+
* then the desk says nothing about a gap being long: it has no idea
|
|
99
|
+
* what long is.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} day
|
|
102
|
+
* @returns {number|null}
|
|
103
|
+
*/
|
|
104
|
+
export function typicalMinutes(day) {
|
|
105
|
+
for (const stats of [day?.durations?.day, day?.durations?.event]) {
|
|
106
|
+
if (stats?.count && Number.isFinite(stats.average)) {
|
|
107
|
+
return stats.average
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What the desk has to say about a court that just came free.
|
|
116
|
+
*
|
|
117
|
+
* behind the next match was due before now
|
|
118
|
+
* idle the court waits longer than a match takes
|
|
119
|
+
* next the next match, on schedule
|
|
120
|
+
* clear nothing left on this court today
|
|
121
|
+
*
|
|
122
|
+
* `minutes` is how late it is for 'behind' and how long the wait is
|
|
123
|
+
* for the other two.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} day
|
|
126
|
+
* @param {object} match - the one that just finished
|
|
127
|
+
* @param {string} [endedAt] - when it finished, defaulting to the
|
|
128
|
+
* match's own end time. Compared as an instant and not as a time of
|
|
129
|
+
* day: a match still waiting from last season really has been
|
|
130
|
+
* waiting since last season, and 7458h is the honest number.
|
|
131
|
+
* @returns {{kind: string, next: ?object, longest: ?object, minutes: ?number, typical: ?number}}
|
|
132
|
+
*/
|
|
133
|
+
export function whatIsNext(day, match, endedAt = match.completed_at) {
|
|
134
|
+
const next = nextOnCourt(day, match)
|
|
135
|
+
const longest = longestWaiting(day, match)
|
|
136
|
+
const typical = typicalMinutes(day)
|
|
137
|
+
|
|
138
|
+
if (!next) {
|
|
139
|
+
return { kind: 'clear', next: null, longest, minutes: null, typical }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const due = Date.parse(next.scheduled_at)
|
|
143
|
+
const now = Date.parse(endedAt)
|
|
144
|
+
const minutes = Math.round(
|
|
145
|
+
(due - (Number.isFinite(now) ? now : Date.now())) / 60000
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if (!Number.isFinite(minutes)) {
|
|
149
|
+
return { kind: 'next', next, longest, minutes: null, typical }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (minutes < 0) {
|
|
153
|
+
return { kind: 'behind', next, longest, minutes: -minutes, typical }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A gap you could have played a match in is a gap worth knowing
|
|
157
|
+
// about: either the day is ahead of its plan or something can be
|
|
158
|
+
// pulled forward into it.
|
|
159
|
+
if (typical !== null && minutes > typical) {
|
|
160
|
+
return { kind: 'idle', next, longest, minutes, typical }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { kind: 'next', next, longest, minutes, typical }
|
|
164
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The small pieces the desk draws in more than one place: a card
|
|
7
|
+
* draws them on a line and a dialog draws them again to say which
|
|
8
|
+
* match it is about.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { teamName } from './gameday-facts.mjs'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An element carrying a data role and nothing else. The role is the
|
|
15
|
+
* hook a host's CSS and a host's own template both address it by.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} name
|
|
18
|
+
* @param {string} role
|
|
19
|
+
* @param {string} text
|
|
20
|
+
* @returns {HTMLElement}
|
|
21
|
+
*/
|
|
22
|
+
export function textTag(name, role, text) {
|
|
23
|
+
const element = document.createElement(name)
|
|
24
|
+
|
|
25
|
+
element.dataset[role] = ''
|
|
26
|
+
element.textContent = text
|
|
27
|
+
|
|
28
|
+
return element
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One side of a match, drawn by <tennis-participant>.
|
|
33
|
+
*
|
|
34
|
+
* A pair arrives as one string with the players separated by commas.
|
|
35
|
+
* Splitting that is the participant element's job and it already does
|
|
36
|
+
* it, along with the flags, so the desk hands it the string and stays
|
|
37
|
+
* out of the way. Two boards splitting the same string two ways is how
|
|
38
|
+
* they end up writing a name differently.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} day
|
|
41
|
+
* @param {string} role - 'home' or 'visitor'
|
|
42
|
+
* @param {number|null} teamId
|
|
43
|
+
* @returns {HTMLElement}
|
|
44
|
+
*/
|
|
45
|
+
export function teamCell(day, role, teamId) {
|
|
46
|
+
const team = day.teams[teamId]
|
|
47
|
+
const cell = document.createElement('tennis-participant')
|
|
48
|
+
|
|
49
|
+
cell.dataset[role] = ''
|
|
50
|
+
cell.setAttribute('label', team?.members ?? teamName(day, teamId))
|
|
51
|
+
|
|
52
|
+
// A team with a name of its own still shows who is in it. An
|
|
53
|
+
// official announcing a Team Cup match is reading the players out,
|
|
54
|
+
// and last year they only had the team's name to read from.
|
|
55
|
+
if (team?.name) {
|
|
56
|
+
cell.setAttribute('team-name', team.name)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return cell
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One box for one set, on one side.
|
|
64
|
+
*
|
|
65
|
+
* A <semtic-input> with the control authored inside it, so a host that
|
|
66
|
+
* never registered semtic still has a working number box: the element
|
|
67
|
+
* is unknown to that page and its child input is what is left.
|
|
68
|
+
*
|
|
69
|
+
* @param {{set: number, value: ?number, label: ?string, disabled: ?boolean}} spec
|
|
70
|
+
* @returns {HTMLElement}
|
|
71
|
+
*/
|
|
72
|
+
export function scoreField({ set, value, label, disabled }) {
|
|
73
|
+
const field = document.createElement('semtic-input')
|
|
74
|
+
const input = document.createElement('input')
|
|
75
|
+
const said = value === undefined || value === null ? '' : String(value)
|
|
76
|
+
|
|
77
|
+
field.setAttribute('type', 'number')
|
|
78
|
+
field.setAttribute('min', '0')
|
|
79
|
+
field.setAttribute('inputmode', 'numeric')
|
|
80
|
+
field.dataset.set = String(set)
|
|
81
|
+
|
|
82
|
+
if (disabled) {
|
|
83
|
+
field.setAttribute('disabled', '')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (label) {
|
|
87
|
+
field.dataset.label = label
|
|
88
|
+
field.setAttribute('title', label)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
input.setAttribute('wc-control', '')
|
|
92
|
+
input.type = 'number'
|
|
93
|
+
input.min = '0'
|
|
94
|
+
input.inputMode = 'numeric'
|
|
95
|
+
input.value = said
|
|
96
|
+
input.disabled = Boolean(disabled)
|
|
97
|
+
|
|
98
|
+
// Also on the control, not only on the host: a host that never
|
|
99
|
+
// registered semtic forwards nothing, and the set number is what
|
|
100
|
+
// says which box a typed score belongs to.
|
|
101
|
+
input.dataset.set = String(set)
|
|
102
|
+
|
|
103
|
+
if (label) {
|
|
104
|
+
input.dataset.label = label
|
|
105
|
+
input.title = label
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
field.setAttribute('value', said)
|
|
109
|
+
field.appendChild(input)
|
|
110
|
+
|
|
111
|
+
return field
|
|
112
|
+
}
|
package/lib/index.mjs
CHANGED
package/lib/knockout-draw.mjs
CHANGED
|
@@ -55,6 +55,7 @@ export class KnockoutDraw {
|
|
|
55
55
|
this.placedInRound = 0
|
|
56
56
|
this.placed = 0
|
|
57
57
|
this.roundCourts = null
|
|
58
|
+
this.lastCourts = null
|
|
58
59
|
this.lastParts = 1
|
|
59
60
|
this.freeFrom = -Infinity
|
|
60
61
|
}
|
|
@@ -203,6 +204,9 @@ export class KnockoutDraw {
|
|
|
203
204
|
this.lastParts = parts
|
|
204
205
|
this.roundIndex += 1
|
|
205
206
|
this.placedInRound = 0
|
|
207
|
+
// Where the round that just ended played. The next one is half
|
|
208
|
+
// as wide and belongs beside it.
|
|
209
|
+
this.lastCourts = this.roundCourts ?? this.lastCourts
|
|
206
210
|
this.roundCourts = null
|
|
207
211
|
this.freeFrom = slotIndex + parts + (parts > 1 ? 0 : restSlots)
|
|
208
212
|
}
|