@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,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
@@ -2,8 +2,6 @@
2
2
  //
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
- const VERSION = "0.0.1"
6
-
7
- export { VERSION }
5
+ const VERSION = "0.0.2";export { VERSION }
8
6
  export { autoFillBracket } from './bracket-autofill.mjs'
9
7
 
@@ -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
  }