@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 CHANGED
@@ -1,5 +1,31 @@
1
1
  Revision history for@opndev/tennis-match-center
2
2
 
3
+ 0.0.2 2026-09-21 03:14:21Z
4
+
5
+ * Add <tennis-gameday-board>, the officials desk: one day split into
6
+ finished, started and pending, with a score box per set the
7
+ division's format expects. Keyboard first, a line tabs score, other
8
+ score, end match, and ending one puts the desk on the next match
9
+ waiting. Changes live in the browser only until the write endpoints
10
+ exist.
11
+
12
+ * Add <tennis-match> and <tennis-participant>: one match with its
13
+ court, stage, state and score, and the two sides that played it.
14
+ The winner is derived from the sets and marked with a `won`
15
+ attribute. Any child carrying `side` is treated as a side, so an
16
+ application with its own participant element keeps using it. Laid
17
+ out like the scheduling board's cell: stage badge, division, then
18
+ both sides on shared rows with the vs between them.
19
+
20
+ * Declare @skirbi/semtic as a peer dependency. The planning and
21
+ schedule boards have built <semtic-tabbed> since they were written
22
+ without the package saying so.
23
+
24
+ * A started match no longer changes court from a select. The court
25
+ becomes a button that opens a <semtic-modal> to confirm the move,
26
+ with a reset back to the planned court. The planned court is never
27
+ rewritten.
28
+
3
29
  0.0.1 2026-09-16 00:12:43Z
4
30
 
5
31
  * First release to an unsuspecting world
@@ -67,6 +67,9 @@ import { MATCH_MINUTES } from './match-time.mjs'
67
67
  * params.courts
68
68
  * @param {Array<{key: string, lit_required: boolean}>} params.slots
69
69
  * Hourly columns, in order.
70
+ * @param {number} [params.lightBuffer] How long before dark an unlit
71
+ * court stops taking new play. Zero holds one open until the light
72
+ * itself goes; left out, PlanningDay's own default applies.
70
73
  * @param {string[]} params.days Y-m-d, in order.
71
74
  * @param {Array<Object>} params.competitions Each carries id, is_pro,
72
75
  * games_needed {group, knockout}, games_needed_by_date,
@@ -93,6 +96,7 @@ export function autoFillPlanning(params) {
93
96
  courtOverrides = [],
94
97
  darkFrom = null,
95
98
  dayEnd = '24:00',
99
+ lightBuffer = undefined,
96
100
  groupSlotsFromSessions = false,
97
101
  splitRounds = false,
98
102
  } = params
@@ -828,6 +832,7 @@ export function autoFillPlanning(params) {
828
832
  occupancy: taken,
829
833
  plan,
830
834
  darkFromMinutes,
835
+ lightBufferMinutes: lightBuffer,
831
836
  })
832
837
 
833
838
  assignKnockout(day, {
@@ -888,26 +893,6 @@ export function autoFillPlanning(params) {
888
893
  return knockoutOverflow(competitions, retried) < overflow ? retried : result
889
894
  }
890
895
 
891
- /**
892
- * How much of the event's knockout did not fit where it was meant to:
893
- * capacity nobody could place at all, plus every cell that landed on
894
- * a day its competition's knockout was never scheduled for.
895
- *
896
- * A competition with no knockout session of its own is not counted -
897
- * it was never given a day to overflow from.
898
- */
899
- /**
900
- * When a session opens, allowing for the rhythm runs are laid out on.
901
- *
902
- * Runs start on a section boundary, so a session opening part way
903
- * through one is due on at the next: that is the rhythm, not lateness.
904
- *
905
- * @param {Array} own The session's own slots, in order.
906
- * @param {string} startTime The session's configured start.
907
- * @param {number} runLength Slots to a run in this phase.
908
- * @returns {?Object} The slot it is due on, or null where the day has
909
- * no such slot at all.
910
- */
911
896
  /**
912
897
  * Whether a group run has to have a lit court.
913
898
  *
@@ -1092,18 +1077,6 @@ function groupKey(competitionId, date) {
1092
1077
  }
1093
1078
 
1094
1079
 
1095
- /**
1096
- * Hands out one court per eligible entry per pass, looping until
1097
- * either the court list or every entry's remaining need is exhausted -
1098
- * so a lone eligible competition gets every court offered, and several
1099
- * eligible competitions split them round by round in priority order.
1100
- *
1101
- * A group-phase entry claims a whole run of consecutive slots on one
1102
- * court at once (group_run_length - a 5-team group's 5 matches), and
1103
- * only on a court that can host every slot of that run. A court that
1104
- * runs out of light partway through cannot host the group at all, so
1105
- * it is never given the run's opening slot either.
1106
- */
1107
1080
  /**
1108
1081
  * Which queued run belongs on a court that has already been taken.
1109
1082
  *
@@ -0,0 +1,288 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { HTMLElementSugar } from '@skirbi/sugar'
6
+
7
+ /**
8
+ * Pixels per minute of real time. A 45-minute slot - the shortest
9
+ * common match length - needs room for four lines (competition,
10
+ * division, group, round) plus padding.
11
+ */
12
+ export const PX_PER_MINUTE = 64 / 45
13
+
14
+ /**
15
+ * The stretch of the day worth drawing: the earliest row's start to
16
+ * the latest row's end.
17
+ *
18
+ * @param {Array} rows
19
+ * @returns {{start: number, end: number}}
20
+ */
21
+ export function dayRange(rows) {
22
+ const starts = rows.map(row => row.start_minutes).filter(minutes => minutes != null)
23
+ const ends = rows.map(row => row.end_minutes).filter(minutes => minutes != null)
24
+
25
+ return starts.length === 0
26
+ ? { start: 0, end: 0 }
27
+ : { start: Math.min(...starts), end: Math.max(...ends) }
28
+ }
29
+
30
+ /**
31
+ * <tennis-day-grid>
32
+ *
33
+ * One day of one board: the hours down the side, a column per court,
34
+ * and a cell for every slot, drawn to scale from real minutes rather
35
+ * than as uniform rows - a 90-minute match reads twice as tall as a
36
+ * 45-minute one, and an idle stretch between two competitions' clocks
37
+ * shows as real blank space.
38
+ *
39
+ * The chrome only. What goes in a cell, and what any gesture on it
40
+ * means, belongs to whichever board is drawing: the grid reports that
41
+ * a cell was pressed, entered or right-clicked and leaves the rest
42
+ * alone. Planning refuses a drop onto an occupied cell where
43
+ * Scheduling swaps the two, and neither rule belongs here.
44
+ */
45
+ export class TennisDayGridElement extends HTMLElementSugar {
46
+ static tag = 'tennis-day-grid'
47
+
48
+ /**
49
+ * Draws a day, replacing whatever was drawn before.
50
+ *
51
+ * @param {Object} day
52
+ * @param {string} day.date
53
+ * @param {Array} day.courts
54
+ * @param {Array} day.rows Slots, in order.
55
+ * @param {Function} day.cellFor Called per court and row, and hands
56
+ * back what that cell is: `blocked` for a cell that may not be
57
+ * used at all, `minutes` where what is in it runs longer than the
58
+ * row, `content` for the element to put inside it, and `filled`
59
+ * where what is in it counts as play - a scheduled cell waiting
60
+ * for a match draws something and holds nothing.
61
+ * @param {number} [day.scale] Pixels per minute. A board whose cell
62
+ * carries four names needs more room per hour than one carrying a
63
+ * division and a group.
64
+ * @param {boolean} [day.compact] Narrow the courts nothing is on.
65
+ */
66
+ draw({ date, courts, rows, cellFor, scale = PX_PER_MINUTE, compact = false }) {
67
+ const range = dayRange(rows)
68
+
69
+ this.date = date
70
+ this.rows = rows
71
+ this.scale = scale
72
+ this.dataset.day = date
73
+ this.setAttribute('date', date)
74
+ this.toggleAttribute('compact', compact)
75
+ this.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
76
+
77
+ this.replaceChildren(
78
+ this.timeColumn(rows, range),
79
+ ...courts.map((court, courtIndex) =>
80
+ this.courtColumn(court, courtIndex, rows, range, cellFor)
81
+ )
82
+ )
83
+ }
84
+
85
+ /**
86
+ * The hours down the side. Its own column rather than a cell in
87
+ * every row, so a court knows nothing about how the day is labelled.
88
+ */
89
+ timeColumn(rows, range) {
90
+ const column = document.createElement('planning-times')
91
+
92
+ // Stands in for the court name, so the hours line up with the
93
+ // slots rather than riding a row above them.
94
+ column.appendChild(document.createElement('planning-times-head'))
95
+
96
+ const body = document.createElement('planning-rows')
97
+ body.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
98
+
99
+ for (const slot of rows) {
100
+ const time = document.createElement('planning-time')
101
+
102
+ time.setAttribute('start', slot.key)
103
+ time.style.top = `${this.pxFor(slot.start_minutes - range.start)}px`
104
+ time.style.height = `${this.pxFor(slot.end_minutes - slot.start_minutes)}px`
105
+ time.textContent = slot.label ?? slot.key
106
+
107
+ column.appendChild(body).appendChild(time)
108
+ }
109
+
110
+ return column
111
+ }
112
+
113
+ courtColumn(court, courtIndex, rows, range, cellFor) {
114
+ const column = document.createElement('planning-court')
115
+
116
+ column.setAttribute('court', court.name)
117
+ column.dataset.courtId = court.id
118
+
119
+ if (court.lit) {
120
+ column.setAttribute('lit', '')
121
+ }
122
+
123
+ if (court.pro) {
124
+ column.setAttribute('pro', '')
125
+ }
126
+
127
+ const heading = document.createElement('planning-court-name')
128
+ heading.textContent = court.name
129
+ column.appendChild(heading)
130
+
131
+ const body = document.createElement('planning-rows')
132
+ body.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
133
+
134
+ // A row's own span is only ever the finest clock running that day
135
+ // - a real match longer than that (Team Cup's 90 minutes among
136
+ // someone else's 45) draws its own cell taller instead, covering
137
+ // however many of these rows its real length reaches. Those
138
+ // covered rows are this court's alone to skip.
139
+ let coveredUntil = -Infinity
140
+ let played = false
141
+
142
+ rows.forEach((slot, rowIndex) => {
143
+ if (slot.start_minutes < coveredUntil) {
144
+ return
145
+ }
146
+
147
+ const cell = cellFor(court, courtIndex, slot, rowIndex) ?? {}
148
+ const element = this.cellElement(cell, slot, rowIndex, courtIndex, range)
149
+
150
+ played = played || (cell.filled ?? Boolean(cell.content))
151
+
152
+ body.appendChild(element)
153
+
154
+ if (!cell.blocked && slot.start_minutes != null) {
155
+ coveredUntil = slot.start_minutes + (cell.minutes ?? (slot.end_minutes - slot.start_minutes))
156
+ }
157
+ })
158
+
159
+ column.appendChild(body)
160
+
161
+ // A court with nothing on it from the first row to the last is
162
+ // still a court - it says where the row is and it can be dropped
163
+ // on - but it need not hold the width of one that is working. The
164
+ // grid only says so; what that is worth is the stylesheet's.
165
+ column.toggleAttribute('empty', !played)
166
+
167
+ return column
168
+ }
169
+
170
+ cellElement(cell, slot, rowIndex, courtIndex, range) {
171
+ const element = document.createElement('planning-slot')
172
+ const minutes = cell.minutes ?? (slot.end_minutes - slot.start_minutes)
173
+
174
+ element.setAttribute('start', slot.key)
175
+ element.style.top = `${this.pxFor(slot.start_minutes - range.start)}px`
176
+ element.style.height = `${this.pxFor(minutes)}px`
177
+
178
+ if (slot.start_minutes != null) {
179
+ element.setAttribute('duration', minutes)
180
+ element.dataset.spanEnd = slot.start_minutes + minutes
181
+ }
182
+
183
+ // A blocked cell is chrome and nothing else: no row, no column,
184
+ // nothing to press. Every board agrees on that much.
185
+ if (cell.blocked) {
186
+ element.setAttribute('blocked', '')
187
+
188
+ return element
189
+ }
190
+
191
+ element.dataset.row = rowIndex
192
+ element.dataset.col = courtIndex
193
+
194
+ element.addEventListener('mousedown', event => {
195
+ this.report('press', event, rowIndex, courtIndex, {
196
+ // Whether the press landed on what is in the cell rather than
197
+ // on the cell itself, which is the difference between taking
198
+ // hold of a match and reaching past it to the grid.
199
+ onContent: event.target !== element,
200
+ button: event.button,
201
+ ctrlKey: event.ctrlKey,
202
+ })
203
+ })
204
+
205
+ element.addEventListener('mouseenter', event => {
206
+ this.report('enter', event, rowIndex, courtIndex)
207
+ })
208
+
209
+ element.addEventListener('contextmenu', event => {
210
+ this.report('menu', event, rowIndex, courtIndex, {
211
+ x: event.clientX ?? 0,
212
+ y: event.clientY ?? 0,
213
+ })
214
+ })
215
+
216
+ if (cell.content) {
217
+ element.appendChild(cell.content)
218
+ }
219
+
220
+ return element
221
+ }
222
+
223
+ report(what, event, row, col, extra = {}) {
224
+ if (what !== 'enter') {
225
+ event.preventDefault()
226
+ }
227
+
228
+ this.dispatchEvent(new CustomEvent(`day-grid:${what}`, {
229
+ bubbles: true,
230
+ detail: { date: this.date, row, col, ...extra },
231
+ }))
232
+ }
233
+
234
+ pxFor(minutes) {
235
+ return minutes * (this.scale ?? PX_PER_MINUTE)
236
+ }
237
+
238
+ /**
239
+ * Shows what is being dragged and where it would land. The boards
240
+ * decide what a drag means; showing one is the same job on both.
241
+ *
242
+ * @param {?Set<string>} from Cells being carried.
243
+ * @param {?string} over Cell under the cursor.
244
+ */
245
+ showDrag(from, over) {
246
+ this.toggleAttribute('dragging', Boolean(from?.size))
247
+
248
+ for (const cell of this.querySelectorAll('planning-slot[data-row]')) {
249
+ const key = `${cell.dataset.row},${cell.dataset.col}`
250
+
251
+ cell.toggleAttribute('data-dragging', Boolean(from?.has(key)))
252
+ cell.toggleAttribute('data-drop', over === key && !from?.has(key))
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Shows which cells are picked out. Toggled directly rather than by
258
+ * redrawing, so dragging a selection across a tournament's worth of
259
+ * cells stays smooth.
260
+ *
261
+ * @param {Set<string>} keys "row,col"
262
+ */
263
+ paint(keys) {
264
+ for (const cell of this.querySelectorAll('planning-slot[data-row]')) {
265
+ cell.toggleAttribute(
266
+ 'data-selected',
267
+ keys.has(`${cell.dataset.row},${cell.dataset.col}`)
268
+ )
269
+ }
270
+ }
271
+
272
+ /**
273
+ * The cells as drawn, court by court, for a board that has
274
+ * something to say about a run of them.
275
+ *
276
+ * @returns {Array}
277
+ */
278
+ columns() {
279
+ return [...this.querySelectorAll('planning-court')].map(column => ({
280
+ courtId: Number(column.dataset.courtId),
281
+ cells: [...column.querySelectorAll('planning-slot')].map(element => ({
282
+ element,
283
+ row: element.dataset.row == null ? null : Number(element.dataset.row),
284
+ })),
285
+ }))
286
+ }
287
+ }
288
+