@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 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', `${this.pxFor(range.end - range.start)}px`)
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', `${this.pxFor(range.end - range.start)}px`)
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 = `${this.pxFor(slot.start_minutes - range.start)}px`
104
- time.style.height = `${this.pxFor(slot.end_minutes - slot.start_minutes)}px`
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', `${this.pxFor(range.end - range.start)}px`)
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 = `${this.pxFor(slot.start_minutes - range.start)}px`
176
- element.style.height = `${this.pxFor(minutes)}px`
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') ?? modal.querySelector('[semtic-modal-cancel]'),
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
- this.sayMatchBlock(
1549
- 'queued',
1550
- said.next && said.next !== longest ? said.next : null,
1551
- `Next planned match on ${court}`
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
- role === 'waiting',
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]'
@@ -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
@@ -2,6 +2,6 @@
2
2
  //
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
- const VERSION = "0.0.2";export { VERSION }
5
+ const VERSION = "0.0.3";export { VERSION }
6
6
  export { autoFillBracket } from './bracket-autofill.mjs'
7
7
 
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  },
41
41
  "sideEffects": true,
42
42
  "type": "module",
43
- "version": "0.0.2"
43
+ "version": "0.0.3"
44
44
  }