@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.
@@ -3,25 +3,58 @@
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
5
  import { HTMLElementSugar } from '@skirbi/sugar'
6
- import { withLitRequired } from '../lighting.mjs'
6
+ import { courtCanTake, toMinutes, withLitRequired } from '../lighting.mjs'
7
7
  import { autoFillPlanning, activeSession } from '../auto-planner.mjs'
8
8
  import { rowsForDate } from '../day-rows.mjs'
9
9
  import { withBusyButton } from '../busy-button.mjs'
10
10
  import { competitionHue, divisionHues } from '../division-hues.mjs'
11
+ import { TennisDayGridElement } from './day-grid.mjs'
12
+ import { fourTeamMatches, fourTeamUnitLayout } from '../four-team-planner.mjs'
13
+ import { threeTeamUnitLayout } from '../three-team-planner.mjs'
14
+ import { unitLayout } from '../paired-court-planner.mjs'
15
+ import { GROUP_TEMPLATES, templateMatches } from '../group-schedule.mjs'
11
16
 
12
17
  const SAVING = { busy: 'Saving…', done: 'Saved', failed: 'Save failed' }
13
18
 
19
+ /**
20
+ * The slots the planner knows how to lay: a group shape, how many
21
+ * groups share the court set, and how many courts that set is. Rest
22
+ * is what the sharing buys - a group waits out the others' turns
23
+ * instead of playing its own matches back to back.
24
+ */
25
+ // How much darker every other turn of a slot is drawn, in percent of
26
+ // lightness - see markRuns.
27
+ const WASH_STEP = 10
28
+
29
+ // What Clear takes off a region: the naming, not the cells. The
30
+ // competition and phase are what painting put there.
31
+ const UNNAMED = {
32
+ division_id: null,
33
+ division_name: null,
34
+ division_gender: null,
35
+ group_id: null,
36
+ group_name: null,
37
+ round: null,
38
+ match_slot: null,
39
+ }
40
+
41
+ const SHAPES = [
42
+ { teams: 3, label: 'no rest', count: 1, courts: 1 },
43
+ { teams: 3, label: 'rest', count: 2, courts: 1 },
44
+ { teams: 4, label: '2 on 2', count: 2, courts: 2 },
45
+ { teams: 4, label: '3 on 2', count: 3, courts: 2 },
46
+ { teams: 5, label: '5 on 1', count: 1, courts: 1 },
47
+ ]
48
+
49
+ function teamIdsFor(group) {
50
+ return Array.from({ length: group.team_count ?? group.pool_size ?? 0 }, (_, i) => i + 1)
51
+ }
52
+
14
53
  // A row's fallback length when a booked block's slot no longer matches
15
54
  // any configured session (spec.md never promises one) - just enough to
16
55
  // give it a real height on the timeline rather than none at all.
17
56
  const FALLBACK_SLOT_MINUTES = 45
18
57
 
19
- // Pixels per minute of real time on the timeline. A 45-minute slot -
20
- // the shortest common match length - needs room for four lines
21
- // (competition, division, group, round) plus padding, not just the
22
- // one or two a bare label used to need.
23
- const PX_PER_MINUTE = 64 / 45
24
-
25
58
  /**
26
59
  * <tennis-planning-board api="..." save-api="...">
27
60
  *
@@ -83,6 +116,11 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
83
116
  // equal parts rather than allowed to run into the small hours or
84
117
  // spill onto the next morning - see auto-planner.mjs's own retry.
85
118
  'day-end': {},
119
+
120
+ // Minutes before dark-from that an unlit court stops taking new
121
+ // play, so nothing is booked to end just as the light goes. Zero
122
+ // holds an unlit court open right up to dark-from.
123
+ 'light-buffer': {},
86
124
  }
87
125
 
88
126
  static HtmlTemplate = ['tennis-planning-board-template', function () {
@@ -107,8 +145,13 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
107
145
  <input data-show-data type="checkbox">
108
146
  Show data
109
147
  </label>
148
+ <label data-narrow-toggle hidden>
149
+ <input data-narrow-empty type="checkbox">
150
+ Narrow empty courts
151
+ </label>
110
152
  <pre data-plan hidden></pre>
111
153
  <div data-schedule hidden></div>
154
+ <planning-menu data-menu hidden></planning-menu>
112
155
  <button data-save type="button" hidden>Save planning</button>
113
156
  </div>
114
157
  `)
@@ -140,6 +183,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
140
183
  this.$('[data-registration-closed]').addEventListener('change', () => this.render())
141
184
  this.$('[data-always-bye-short-groups]').addEventListener('change', () => this.render())
142
185
  this.$('[data-show-data]').addEventListener('change', () => this.render())
186
+ this.$('[data-narrow-empty]').addEventListener('change', () => this.render())
143
187
  this.$('[data-clear-all]').addEventListener('click', () => this.clearAll())
144
188
  this.$('[data-clear-day]').addEventListener('click', () => {
145
189
  const activeDate = this.$('[data-schedule] semtic-tabbed')?.getAttribute('current')
@@ -151,12 +195,24 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
151
195
  // ends the selection instead of leaving it stuck "selecting".
152
196
  window.addEventListener('mouseup', () => {
153
197
  this.isSelecting = false
198
+ this.drop()
199
+ })
200
+
201
+ window.addEventListener('mousedown', event => {
202
+ if (!this.$('[data-menu]').contains(event.target)) {
203
+ this.closeMenu()
204
+ }
154
205
  })
155
206
 
156
207
  // Backspace/Delete clears the current selection - the same
157
208
  // action as clicking "Clear selection" in the legend, just
158
209
  // faster for a director working through a lot of cells.
159
210
  window.addEventListener('keydown', event => {
211
+ if (event.key === 'Escape') {
212
+ this.closeMenu()
213
+ return
214
+ }
215
+
160
216
  if (event.key !== 'Backspace' && event.key !== 'Delete') {
161
217
  return
162
218
  }
@@ -283,9 +339,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
283
339
  }
284
340
 
285
341
  isSelectable(court, slot) {
286
- // A physical impossibility, not a soft preference - spec.md: "a
287
- // non-lit court cannot be assigned to the evening slot".
288
- return !(slot.lit_required && !court.lit)
342
+ return courtCanTake(court, slot)
289
343
  }
290
344
 
291
345
  cellKey(row, col) {
@@ -343,16 +397,18 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
343
397
  * worth of cells. Scoped to the active day only.
344
398
  */
345
399
  paintSelection() {
346
- const day = this.$(`planning-day[data-day="${this.selectionDate}"]`)
400
+ this.gridFor(this.selectionDate)?.paint(this.selection)
401
+ }
347
402
 
348
- if (!day) {
349
- return
350
- }
403
+ gridFor(date) {
404
+ return this.$(`tennis-day-grid[data-day="${date}"]`)
405
+ }
351
406
 
352
- for (const slot of day.querySelectorAll('planning-slot[data-row]')) {
353
- const key = this.cellKey(Number(slot.dataset.row), Number(slot.dataset.col))
354
- slot.toggleAttribute('data-selected', this.selection.has(key))
355
- }
407
+ showDrag() {
408
+ this.gridFor(this.drag?.date ?? this.selectionDate)?.showDrag(
409
+ this.drag ? new Set(this.drag.keys) : null,
410
+ this.drag?.over ? this.cellKey(this.drag.over.row, this.drag.over.col) : null
411
+ )
356
412
  }
357
413
 
358
414
  /**
@@ -398,6 +454,682 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
398
454
  this.render()
399
455
  }
400
456
 
457
+ /**
458
+ * Picks up what is under the cursor: the whole selection if the
459
+ * grabbed cell is part of it, otherwise that one block.
460
+ *
461
+ * @param {string} date
462
+ * @param {number} row
463
+ * @param {number} col
464
+ */
465
+ startDrag(date, row, col) {
466
+ const grabbed = this.cellKey(row, col)
467
+ const keys = this.selectionDate === date && this.selection.has(grabbed)
468
+ ? [...this.selection]
469
+ : [grabbed]
470
+
471
+ const rows = this.rowsFor(date)
472
+
473
+ this.drag = {
474
+ date,
475
+ row,
476
+ col,
477
+ over: null,
478
+ keys: keys.filter(key => {
479
+ const [r, c] = key.split(',').map(Number)
480
+
481
+ return this.blockAt(this.state.courts[c].id, date, rows[r].key)
482
+ }),
483
+ }
484
+
485
+ this.showDrag()
486
+ }
487
+
488
+ /**
489
+ * Drops what is being dragged, moving every block by the same
490
+ * row/court step. A step that would land on a blocked cell, or on a
491
+ * block that is staying put, is refused outright: nothing is
492
+ * overwritten and nothing moves halfway.
493
+ */
494
+ drop() {
495
+ const drag = this.drag
496
+
497
+ this.drag = null
498
+ this.gridFor(drag?.date ?? this.selectionDate)?.showDrag(null, null)
499
+
500
+ if (!drag?.over || !drag.keys.length) {
501
+ return
502
+ }
503
+
504
+ const rowStep = drag.over.row - drag.row
505
+ const colStep = drag.over.col - drag.col
506
+
507
+ if (rowStep === 0 && colStep === 0) {
508
+ return
509
+ }
510
+
511
+ const rows = this.rowsFor(drag.date)
512
+ const moving = new Set(drag.keys)
513
+ const moves = []
514
+
515
+ for (const key of drag.keys) {
516
+ const [row, col] = key.split(',').map(Number)
517
+ const toRow = row + rowStep
518
+ const toCol = col + colStep
519
+ const court = this.state.courts[toCol]
520
+ const slot = rows[toRow]
521
+
522
+ if (!court || !slot || !this.isSelectable(court, slot)) {
523
+ return
524
+ }
525
+
526
+ const sitting = this.blockAt(court.id, drag.date, slot.key)
527
+
528
+ if (sitting && !moving.has(this.cellKey(toRow, toCol))) {
529
+ return
530
+ }
531
+
532
+ moves.push({
533
+ block: this.blockAt(this.state.courts[col].id, drag.date, rows[row].key),
534
+ courtId: court.id,
535
+ slot: slot.key,
536
+ key: this.cellKey(toRow, toCol),
537
+ })
538
+ }
539
+
540
+ for (const move of moves) {
541
+ move.block.court_id = move.courtId
542
+ move.block.slot = move.slot
543
+ }
544
+
545
+ this.selection = new Set(moves.map(move => move.key))
546
+ this.selectionDate = drag.date
547
+
548
+ this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
549
+ bubbles: true,
550
+ detail: { blocks: this.blocks },
551
+ }))
552
+
553
+ this.render()
554
+ this.paintSelection()
555
+ }
556
+
557
+ /**
558
+ * A group's own matches, off the same templates the planner lays
559
+ * out from.
560
+ *
561
+ * @param {Object} group
562
+ * @returns {Array}
563
+ */
564
+ groupMatches(group) {
565
+ const teamIds = teamIdsFor(group)
566
+
567
+ return group.pool_size === 4
568
+ ? fourTeamMatches({ teamIds })
569
+ : templateMatches({ teamIds, groupSize: group.pool_size })
570
+ }
571
+
572
+ /**
573
+ * How much room the painted region has, in courts and slots.
574
+ *
575
+ * @returns {Object}
576
+ */
577
+ selectionSize() {
578
+ const cells = [...this.selection].map(key => key.split(',').map(Number))
579
+
580
+ return {
581
+ courts: new Set(cells.map(([, col]) => col)).size,
582
+ slots: new Set(cells.map(([row]) => row)).size,
583
+ }
584
+ }
585
+
586
+ /**
587
+ * The competition's groups that still owe court time. A group
588
+ * already holding all its cells has nothing left to slot.
589
+ *
590
+ * @param {Object} competition
591
+ * @returns {Array}
592
+ */
593
+ owingGroups(competition) {
594
+ return (competition.groups ?? []).filter(group => {
595
+ const needed = this.groupMatches(group).length
596
+ const planned = this.blocks.filter(block => block.group_id === group.id).length
597
+
598
+ return needed > 0 && planned < needed
599
+ })
600
+ }
601
+
602
+ /**
603
+ * The slots the planner could lay into the painted region, by group
604
+ * shape. Only shapes with groups left to place, and only groups
605
+ * that still owe court time. Anything else is manual.
606
+ *
607
+ * @returns {Array}
608
+ */
609
+ slotOptions(context) {
610
+ if (context?.phase !== 'group') {
611
+ return []
612
+ }
613
+
614
+ const competition = this.state.competitions.find(c => c.id === context.competitionId)
615
+
616
+ if (!competition) {
617
+ return []
618
+ }
619
+
620
+ const owing = this.owingGroups(competition)
621
+ const region = this.selectionSize()
622
+
623
+ return SHAPES
624
+ .map(shape => {
625
+ const groups = owing.filter(group => group.pool_size === shape.teams)
626
+
627
+ if (groups.length < shape.count) {
628
+ return null
629
+ }
630
+
631
+ const slots = (this.groupMatches(groups[0]).length / shape.courts) * shape.count
632
+
633
+ return {
634
+ ...shape,
635
+ groups,
636
+ slots,
637
+ fits: region.courts >= shape.courts && region.slots >= slots,
638
+ }
639
+ })
640
+ .filter(Boolean)
641
+ }
642
+
643
+ /**
644
+ * How the planner lays this shape out: 4-team takes a pair of
645
+ * courts, 3-team and 5-team one, and every group in the unit takes
646
+ * its turn on it.
647
+ *
648
+ * @param {Array} groups
649
+ * @returns {Array}
650
+ */
651
+ layoutFor(groups) {
652
+ const unit = groups.map(group => ({ id: group.id, teamIds: teamIdsFor(group) }))
653
+
654
+ if (groups[0].pool_size === 4) {
655
+ return fourTeamUnitLayout(unit)
656
+ }
657
+
658
+ if (groups[0].pool_size === 3) {
659
+ return threeTeamUnitLayout(unit)
660
+ }
661
+
662
+ return unitLayout(unit, {
663
+ template: GROUP_TEMPLATES[5],
664
+ matchesFor: group => templateMatches({ teamIds: group.teamIds, groupSize: 5 }),
665
+ })
666
+ }
667
+
668
+ /**
669
+ * Lays the chosen groups into the region, turn by turn, exactly as
670
+ * the planner would place them.
671
+ *
672
+ * @param {Array} groups
673
+ */
674
+ createSlot(groups, context) {
675
+ if (!groups.length || !context) {
676
+ return
677
+ }
678
+
679
+ const layout = this.layoutFor(groups)
680
+
681
+ const date = this.selectionDate
682
+ const rows = this.rowsFor(date)
683
+ const cells = [...this.selection].map(key => key.split(',').map(Number))
684
+ const rowIndexes = [...new Set(cells.map(([row]) => row))].sort((a, b) => a - b)
685
+ const colIndexes = [...new Set(cells.map(([, col]) => col))].sort((a, b) => a - b)
686
+ const width = Math.max(...layout.map(slot => slot.length))
687
+
688
+ if (colIndexes.length < width || rowIndexes.length < layout.length) {
689
+ return
690
+ }
691
+
692
+ const competitionId = context.competitionId
693
+
694
+ for (const [slotIndex, slot] of layout.entries()) {
695
+ for (let courtIndex = 0; courtIndex < width; courtIndex++) {
696
+ const court = this.state.courts[colIndexes[courtIndex]]
697
+ const row = rows[rowIndexes[slotIndex]]
698
+
699
+ this.blocks = this.blocks.filter(block =>
700
+ !(block.court_id === court.id && block.date === date && block.slot === row.key)
701
+ )
702
+
703
+ const entry = slot[courtIndex]
704
+
705
+ if (!entry) {
706
+ continue
707
+ }
708
+
709
+ const group = groups.find(candidate => candidate.id === entry.groupId)
710
+
711
+ this.blocks.push({
712
+ id: null,
713
+ court_id: court.id,
714
+ date,
715
+ slot: row.key,
716
+ phase: 'group',
717
+ competition_id: competitionId,
718
+ division_id: group.division_id,
719
+ division_name: group.division_name,
720
+ division_gender: group.division_gender,
721
+ group_id: group.id,
722
+ group_name: group.name,
723
+ match_slot: entry.index,
724
+ })
725
+ }
726
+ }
727
+
728
+ this.closeMenu()
729
+
730
+ this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
731
+ bubbles: true,
732
+ detail: { blocks: this.blocks },
733
+ }))
734
+
735
+ this.render()
736
+ this.paintSelection()
737
+ }
738
+
739
+ /**
740
+ * The blocks under the current selection.
741
+ *
742
+ * @returns {Array}
743
+ */
744
+ selectedBlocks() {
745
+ if (!this.selectionDate) {
746
+ return []
747
+ }
748
+
749
+ const rows = this.rowsFor(this.selectionDate)
750
+
751
+ return [...this.selection]
752
+ .map(key => {
753
+ const [row, col] = key.split(',').map(Number)
754
+
755
+ return this.blockAt(this.state.courts[col].id, this.selectionDate, rows[row].key)
756
+ })
757
+ .filter(Boolean)
758
+ }
759
+
760
+ /**
761
+ * Which competition and phase the region is being worked on as:
762
+ * what is painted there, or - on cells with nothing on them yet -
763
+ * whatever the menu was told to treat them as.
764
+ *
765
+ * @returns {?Object}
766
+ */
767
+ selectionContext() {
768
+ const blocks = this.selectedBlocks()
769
+ const fixtures = new Set(blocks.map(block => `${block.competition_id}:${block.phase}`))
770
+
771
+ if (fixtures.size === 1) {
772
+ return { competitionId: blocks[0].competition_id, phase: blocks[0].phase }
773
+ }
774
+
775
+ return blocks.length === 0 ? this.menuContext ?? null : null
776
+ }
777
+
778
+ /**
779
+ * What the region can be told it is: the divisions of its
780
+ * competition, each with its groups or its rounds.
781
+ *
782
+ * @param {?Object} context
783
+ * @returns {?Array}
784
+ */
785
+ identityOptions(context) {
786
+ const competition = context
787
+ && this.state.competitions.find(c => c.id === context.competitionId)
788
+
789
+ if (!competition) {
790
+ return null
791
+ }
792
+
793
+ return context.phase === 'knockout'
794
+ ? this.roundOptions(competition)
795
+ : this.groupOptions(competition)
796
+ }
797
+
798
+ groupOptions(competition) {
799
+ const divisions = new Map()
800
+
801
+ for (const group of competition.groups ?? []) {
802
+ if (!divisions.has(group.division_id)) {
803
+ divisions.set(group.division_id, {
804
+ id: group.division_id,
805
+ name: group.division_name,
806
+ items: [],
807
+ })
808
+ }
809
+
810
+ divisions.get(group.division_id).items.push({
811
+ label: `Group ${group.name}`,
812
+ fixture: {
813
+ division_id: group.division_id,
814
+ division_name: group.division_name,
815
+ division_gender: group.division_gender,
816
+ group_id: group.id,
817
+ group_name: group.name,
818
+ round: null,
819
+ },
820
+ })
821
+ }
822
+
823
+ return [...divisions.values()]
824
+ }
825
+
826
+ roundOptions(competition) {
827
+ return (competition.knockout_rounds_by_division ?? []).map(division => ({
828
+ id: division.division_id,
829
+ name: division.division_name,
830
+ items: division.waves.map(wave => ({
831
+ label: wave.round,
832
+ fixture: {
833
+ division_id: division.division_id,
834
+ division_name: division.division_name,
835
+ division_gender: division.division_gender,
836
+ group_id: null,
837
+ group_name: null,
838
+ round: wave.round,
839
+ },
840
+ })),
841
+ }))
842
+ }
843
+
844
+ /**
845
+ * Opens the menu at the pointer: what the planner would lay into
846
+ * the region, then the divisions and groups to name it by hand.
847
+ * Nothing to offer means no menu.
848
+ *
849
+ * @param {number} x
850
+ * @param {number} y
851
+ */
852
+ openMenu(x, y) {
853
+ const menu = this.$('[data-menu]')
854
+ const context = this.selectionContext()
855
+
856
+ menu.replaceChildren()
857
+
858
+ if (!context) {
859
+ this.offerCompetitions(x, y)
860
+ return
861
+ }
862
+
863
+ const slots = this.slotOptions(context)
864
+ const identities = this.identityOptions(context) ?? []
865
+ const painted = this.selectedBlocks().length > 0
866
+
867
+ if (!slots.length && !identities.length && !painted) {
868
+ menu.hidden = true
869
+ return
870
+ }
871
+
872
+ const list = document.createElement('ul')
873
+
874
+ if (slots.length) {
875
+ const region = this.selectionSize()
876
+
877
+ list.appendChild(this.menuSection(
878
+ `Assign slots (${region.courts} x ${region.slots} selected)`,
879
+ slots.map(option => {
880
+ const button = this.menuButton(
881
+ `${option.teams} team: ${option.label} (needs ${option.courts} x ${option.slots})`,
882
+ () => this.openSlotPicker(option, context)
883
+ )
884
+
885
+ button.dataset.slotCreate = ''
886
+ button.disabled = !option.fits
887
+
888
+ return button
889
+ })
890
+ ))
891
+ }
892
+
893
+ if (identities.length) {
894
+ list.appendChild(this.menuSection('Manual assignment', identities.map(division => {
895
+ const button = this.menuButton(division.name, () => this.openManualPicker(division, context))
896
+
897
+ button.dataset.menuDivision = division.id
898
+
899
+ return button
900
+ })))
901
+ }
902
+
903
+ if (painted) {
904
+ const clear = this.menuButton('Clear', () => this.assignIdentity(UNNAMED))
905
+
906
+ clear.dataset.menuClear = ''
907
+ list.appendChild(this.menuSection('', [clear]))
908
+ }
909
+
910
+ menu.appendChild(list)
911
+ this.showMenu(x, y)
912
+ }
913
+
914
+ /**
915
+ * A region with nothing on it has no competition to work from, so
916
+ * the menu asks for one before it can offer anything.
917
+ *
918
+ * @param {number} x
919
+ * @param {number} y
920
+ */
921
+ offerCompetitions(x, y) {
922
+ const menu = this.$('[data-menu]')
923
+ const entries = this.competitionsForDate(this.selectionDate)
924
+
925
+ if (!this.selection.size || !entries.length) {
926
+ menu.hidden = true
927
+ return
928
+ }
929
+
930
+ const list = document.createElement('ul')
931
+
932
+ list.appendChild(this.menuSection('Which competition', entries.map(({ competition, phase }) => {
933
+ const button = this.menuButton(
934
+ `${competition.name} (${phase === 'knockout' ? 'Knockout' : 'Group'})`,
935
+ () => {
936
+ this.menuContext = { competitionId: competition.id, phase }
937
+ this.openMenu(x, y)
938
+ }
939
+ )
940
+
941
+ button.dataset.menuCompetition = competition.id
942
+
943
+ return button
944
+ })))
945
+
946
+ menu.appendChild(list)
947
+ this.showMenu(x, y)
948
+ }
949
+
950
+ /**
951
+ * Which groups go in the slot. The planner bundles a fixed number
952
+ * per court set, so the button waits for exactly that many.
953
+ *
954
+ * @param {Object} option
955
+ * @param {Object} context
956
+ */
957
+ openSlotPicker(option, context) {
958
+ const menu = this.$('[data-menu]')
959
+ const chosen = new Set()
960
+
961
+ const create = this.menuButton('Assign', () => this.createSlot([...chosen], context))
962
+ create.dataset.slotConfirm = ''
963
+
964
+ // A competition can have sixty groups, and the list of them buries
965
+ // the button. Once the slot has the groups it takes, only those
966
+ // are left on show.
967
+ const draw = () => {
968
+ const full = chosen.size === option.count
969
+ const shown = full ? [...chosen] : option.groups
970
+
971
+ create.disabled = !full
972
+
973
+ const boxes = shown.map(group => {
974
+ const label = document.createElement('label')
975
+ const box = document.createElement('input')
976
+
977
+ box.type = 'checkbox'
978
+ box.checked = chosen.has(group)
979
+ box.dataset.slotGroup = group.id
980
+ box.addEventListener('change', () => {
981
+ if (box.checked) {
982
+ chosen.add(group)
983
+ }
984
+ else {
985
+ chosen.delete(group)
986
+ }
987
+
988
+ draw()
989
+ })
990
+
991
+ label.append(box, document.createTextNode(` ${group.division_name} ${group.name}`))
992
+
993
+ return label
994
+ })
995
+
996
+ menu.replaceChildren()
997
+ menu.appendChild(document.createElement('ul')).appendChild(
998
+ this.menuSection(
999
+ full
1000
+ ? option.label
1001
+ : `${option.label}: pick ${option.count} ${option.count === 1 ? 'group' : 'groups'}`,
1002
+ [...boxes, create]
1003
+ )
1004
+ )
1005
+ }
1006
+
1007
+ draw()
1008
+ menu.hidden = false
1009
+ }
1010
+
1011
+ /**
1012
+ * Which group or round the region is. One answer, picked the same
1013
+ * way a slot's groups are.
1014
+ *
1015
+ * @param {Object} division
1016
+ */
1017
+ openManualPicker(division, context) {
1018
+ const menu = this.$('[data-menu]')
1019
+ let chosen = null
1020
+
1021
+ const assign = this.menuButton('Assign', () => this.assignIdentity(chosen, context))
1022
+ assign.dataset.menuConfirm = ''
1023
+ assign.disabled = true
1024
+
1025
+ const choices = division.items.map(option => {
1026
+ const label = document.createElement('label')
1027
+ const radio = document.createElement('input')
1028
+
1029
+ radio.type = 'radio'
1030
+ radio.name = 'manual-assign'
1031
+ radio.dataset.menuAssign = option.label
1032
+ radio.addEventListener('change', () => {
1033
+ chosen = option.fixture
1034
+ assign.disabled = false
1035
+ })
1036
+
1037
+ label.append(radio, document.createTextNode(` ${option.label}`))
1038
+
1039
+ return label
1040
+ })
1041
+
1042
+ menu.replaceChildren()
1043
+ menu.appendChild(document.createElement('ul')).appendChild(
1044
+ this.menuSection(division.name, [...choices, assign])
1045
+ )
1046
+
1047
+ menu.hidden = false
1048
+ }
1049
+
1050
+ menuButton(label, onClick) {
1051
+ const button = document.createElement('button')
1052
+
1053
+ button.type = 'button'
1054
+ button.textContent = label
1055
+ button.addEventListener('click', onClick)
1056
+
1057
+ return button
1058
+ }
1059
+
1060
+ menuSection(title, children) {
1061
+ const item = document.createElement('li')
1062
+ const name = document.createElement('strong')
1063
+
1064
+ name.textContent = title
1065
+ item.append(name, ...children)
1066
+
1067
+ return item
1068
+ }
1069
+
1070
+ showMenu(x, y) {
1071
+ const menu = this.$('[data-menu]')
1072
+
1073
+ menu.style.left = `${x}px`
1074
+ menu.style.top = `${y}px`
1075
+ menu.hidden = false
1076
+ }
1077
+
1078
+ closeMenu() {
1079
+ const menu = this.$('[data-menu]')
1080
+
1081
+ menu.hidden = true
1082
+ menu.replaceChildren()
1083
+ this.menuContext = null
1084
+ }
1085
+
1086
+ /**
1087
+ * Names the selection: every block in it becomes this division and
1088
+ * group, or this division and round. The selection stays, so a
1089
+ * region can be renamed without picking it out again.
1090
+ *
1091
+ * @param {Object} fixture
1092
+ */
1093
+ assignIdentity(fixture, context) {
1094
+ const rows = this.rowsFor(this.selectionDate)
1095
+
1096
+ for (const key of this.selection) {
1097
+ const [row, col] = key.split(',').map(Number)
1098
+ const court = this.state.courts[col]
1099
+ const slot = rows[row]
1100
+ const block = this.blockAt(court.id, this.selectionDate, slot.key)
1101
+
1102
+ if (block) {
1103
+ Object.assign(block, fixture)
1104
+ continue
1105
+ }
1106
+
1107
+ // Nothing painted there yet: the menu was told what to treat
1108
+ // the region as, so the cell is made rather than skipped.
1109
+ if (context) {
1110
+ this.blocks.push({
1111
+ id: null,
1112
+ court_id: court.id,
1113
+ date: this.selectionDate,
1114
+ slot: slot.key,
1115
+ phase: context.phase,
1116
+ competition_id: context.competitionId,
1117
+ ...fixture,
1118
+ })
1119
+ }
1120
+ }
1121
+
1122
+ this.closeMenu()
1123
+
1124
+ this.dispatchEvent(new CustomEvent('tennis-planning:changed', {
1125
+ bubbles: true,
1126
+ detail: { blocks: this.blocks },
1127
+ }))
1128
+
1129
+ this.render()
1130
+ this.paintSelection()
1131
+ }
1132
+
401
1133
  /**
402
1134
  * How many one-hour matches are currently scheduled for a
403
1135
  * competition in the given phase, so the legend can show
@@ -438,6 +1170,24 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
438
1170
  return this.$('[data-always-bye-short-groups]')?.checked ?? false
439
1171
  }
440
1172
 
1173
+ /**
1174
+ * Left off the element, the planner's own default stands. "0" is a
1175
+ * real answer, hence no `|| undefined`.
1176
+ *
1177
+ * @returns {?number}
1178
+ */
1179
+ lightBuffer() {
1180
+ const raw = this.getConfig()['light-buffer']
1181
+
1182
+ if (raw == null || String(raw).trim() === '') {
1183
+ return undefined
1184
+ }
1185
+
1186
+ const minutes = Number(raw)
1187
+
1188
+ return Number.isFinite(minutes) && minutes >= 0 ? minutes : undefined
1189
+ }
1190
+
441
1191
  /**
442
1192
  * The groups that will be pooled rather than given a court of their
443
1193
  * own. Worth saying out loud: they are the ones a director can still
@@ -489,6 +1239,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
489
1239
  groupSlotsFromSessions: true,
490
1240
  darkFrom: this.getConfig()['dark-from'],
491
1241
  dayEnd: this.getConfig()['day-end'] || undefined,
1242
+ lightBuffer: this.lightBuffer(),
492
1243
  })
493
1244
 
494
1245
  this.blocks = blocks
@@ -566,6 +1317,7 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
566
1317
  this.$('[data-registration]').hidden = false
567
1318
  this.$('[data-always-bye]').hidden = false
568
1319
  this.$('[data-view-toggle]').hidden = false
1320
+ this.$('[data-narrow-toggle]').hidden = false
569
1321
 
570
1322
  // The plan as data, for reading rather than looking at.
571
1323
  const showData = this.$('[data-show-data]').checked
@@ -646,6 +1398,12 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
646
1398
  // re-renders the whole schedule, and without this it would always
647
1399
  // snap back to the first day.
648
1400
  const activeDate = container.querySelector('semtic-tabbed')?.getAttribute('current')
1401
+ // A day scrolls inside its own tab, and that tab is thrown away
1402
+ // here, so a director working on court 30 would be handed back
1403
+ // court 1 after every assignment.
1404
+ const wasAt = this.scrollPosition(
1405
+ container.querySelector(`semtic-tab[tab="${activeDate}"]`)
1406
+ )
649
1407
 
650
1408
  container.replaceChildren()
651
1409
 
@@ -701,6 +1459,33 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
701
1459
  tabbed.addEventListener('semtic-tabbed:activated', event => buildDay(event.detail.tab))
702
1460
 
703
1461
  container.appendChild(tabbed)
1462
+ this.scrollBackTo(tabbed.querySelector(`:scope > semtic-tab[tab="${initial}"]`), wasAt)
1463
+ }
1464
+
1465
+ scrollPosition(tab) {
1466
+ const view = this.ownerDocument?.defaultView
1467
+
1468
+ return {
1469
+ left: tab?.scrollLeft ?? 0,
1470
+ top: tab?.scrollTop ?? 0,
1471
+ pageX: view?.scrollX ?? 0,
1472
+ pageY: view?.scrollY ?? 0,
1473
+ }
1474
+ }
1475
+
1476
+ scrollBackTo(tab, wasAt) {
1477
+ if (tab) {
1478
+ tab.scrollLeft = wasAt.left
1479
+ tab.scrollTop = wasAt.top
1480
+ }
1481
+
1482
+ const view = this.ownerDocument?.defaultView
1483
+
1484
+ // Only when it actually moved: asking otherwise is how a board
1485
+ // ends up fighting whatever else is scrolling the page.
1486
+ if (view && (view.scrollX !== wasAt.pageX || view.scrollY !== wasAt.pageY)) {
1487
+ view.scrollTo?.(wasAt.pageX, wasAt.pageY)
1488
+ }
704
1489
  }
705
1490
 
706
1491
  /**
@@ -772,6 +1557,8 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
772
1557
  competitions: this.state.competitions ?? [],
773
1558
  cells: this.blocks,
774
1559
  fallbackSlots: this.state.slots ?? [],
1560
+ darkFromMinutes: toMinutes(this.getConfig()['dark-from']),
1561
+ lightBufferMinutes: this.lightBuffer() ?? 0,
775
1562
  })
776
1563
  }
777
1564
 
@@ -875,106 +1662,144 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
875
1662
  * canvas is continuous, the cells on it are not.
876
1663
  */
877
1664
  dayForDate(date) {
878
- const day = document.createElement('planning-day')
879
- day.dataset.day = date
880
- day.setAttribute('date', date)
881
-
1665
+ const grid = document.createElement(TennisDayGridElement.tag)
882
1666
  const rows = this.rowsFor(date)
883
- const range = this.dayRangeFor(rows)
884
1667
 
885
- day.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
1668
+ grid.addEventListener('day-grid:press', event => this.pressed(event.detail))
1669
+ grid.addEventListener('day-grid:enter', event => this.entered(event.detail))
1670
+ grid.addEventListener('day-grid:menu', event => this.rightClicked(event.detail))
886
1671
 
887
- day.appendChild(this.timeColumn(rows, range))
888
-
889
- this.state.courts.forEach((court, courtIndex) => {
890
- day.appendChild(this.courtColumn(court, courtIndex, date, rows, range))
1672
+ grid.draw({
1673
+ date,
1674
+ courts: this.state.courts,
1675
+ rows,
1676
+ cellFor: (court, courtIndex, slot) => this.cellFor(court, date, slot),
1677
+ compact: this.$('[data-narrow-empty]')?.checked ?? false,
891
1678
  })
892
1679
 
893
- return day
1680
+ this.markRuns(grid, date, rows)
1681
+
1682
+ return grid
894
1683
  }
895
1684
 
896
1685
  /**
897
- * Minutes of the day, in pixels on the timeline.
1686
+ * What Planning puts in a cell: nothing on a court that may not
1687
+ * take this hour, and otherwise whatever is booked there.
1688
+ *
1689
+ * @param {Object} court
1690
+ * @param {string} date
1691
+ * @param {Object} slot
1692
+ * @returns {Object}
898
1693
  */
899
- pxFor(minutes) {
900
- return minutes * PX_PER_MINUTE
1694
+ cellFor(court, date, slot) {
1695
+ if (!this.isSelectable(court, slot)) {
1696
+ return { blocked: true }
1697
+ }
1698
+
1699
+ const block = this.blockAt(court.id, date, slot.key)
1700
+
1701
+ return {
1702
+ minutes: block ? this.blockDurationMinutes(block, date) : undefined,
1703
+ content: block ? this.matchElement(block) : null,
1704
+ filled: Boolean(block),
1705
+ }
901
1706
  }
902
1707
 
903
1708
  /**
904
- * The hours down the side. Its own column rather than a cell in
905
- * every row, so a court knows nothing about how the day is labelled.
1709
+ * A press on the grid: on a match already picked out it takes hold
1710
+ * of it, and anywhere else it starts a selection.
1711
+ *
1712
+ * @param {Object} where
906
1713
  */
907
- timeColumn(rows, range) {
908
- const column = document.createElement('planning-times')
909
-
910
- // Stands in for the court name, so the hours line up with the
911
- // slots rather than riding a row above them.
912
- column.appendChild(document.createElement('planning-times-head'))
913
-
914
- const body = document.createElement('planning-rows')
915
- body.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
916
-
917
- rows.forEach(slot => {
918
- const time = document.createElement('planning-time')
919
- time.setAttribute('start', slot.key)
920
- time.style.top = `${this.pxFor(slot.start_minutes - range.start)}px`
921
- time.style.height = `${this.pxFor(slot.end_minutes - slot.start_minutes)}px`
922
- time.textContent = slot.label ?? slot.key
923
- body.appendChild(time)
924
- })
1714
+ pressed({ date, row, col, onContent, button, ctrlKey }) {
1715
+ // A mac's ctrl-click is a right-click, and its mousedown lands
1716
+ // first. Acting on it would throw away the very region the menu
1717
+ // is about to work on.
1718
+ if (button === 2 || ctrlKey) {
1719
+ return
1720
+ }
925
1721
 
926
- column.appendChild(body)
1722
+ const mine = this.selectionDate === date && this.selection.has(this.cellKey(row, col))
927
1723
 
928
- return column
929
- }
1724
+ if (onContent && mine) {
1725
+ this.startDrag(date, row, col)
930
1726
 
931
- courtColumn(court, courtIndex, date, rows, range) {
932
- const column = document.createElement('planning-court')
933
- column.setAttribute('court', court.name)
934
- column.dataset.courtId = court.id
1727
+ return
1728
+ }
1729
+
1730
+ this.startSelection(date, row, col)
1731
+ }
935
1732
 
936
- if (court.lit) {
937
- column.setAttribute('lit', '')
1733
+ entered({ date, row, col }) {
1734
+ if (this.isSelecting && this.selectionDate === date) {
1735
+ this.updateSelection(row, col)
938
1736
  }
939
1737
 
940
- if (court.pro) {
941
- column.setAttribute('pro', '')
1738
+ if (this.drag?.date === date) {
1739
+ this.drag.over = { row, col }
1740
+ this.showDrag()
942
1741
  }
1742
+ }
943
1743
 
944
- const heading = document.createElement('planning-court-name')
945
- heading.textContent = court.name
946
- column.appendChild(heading)
1744
+ /**
1745
+ * Right-clicking outside the painted region takes that one cell
1746
+ * rather than doing nothing.
1747
+ *
1748
+ * @param {Object} where
1749
+ */
1750
+ rightClicked({ date, row, col, x, y }) {
1751
+ if (this.selectionDate !== date || !this.selection.has(this.cellKey(row, col))) {
1752
+ this.startSelection(date, row, col)
1753
+ this.isSelecting = false
1754
+ }
947
1755
 
948
- const body = document.createElement('planning-rows')
949
- body.style.setProperty('--planning-day-height', `${this.pxFor(range.end - range.start)}px`)
1756
+ this.openMenu(x, y)
1757
+ }
950
1758
 
951
- // A row's own span is only ever the finest clock running that day
952
- // (see rowsFor) - a real match longer than that (Team Cup's 90
953
- // minutes among someone else's 45) draws its own slot taller
954
- // instead, covering however many of these rows its real length
955
- // reaches. Those covered rows are this court's alone to skip - a
956
- // different court still gets its own row here, whatever its own
957
- // match (or lack of one) actually needs.
958
- let coveredUntil = -Infinity
1759
+ /**
1760
+ * A slot is a run of cells down one court, all of one division. The
1761
+ * first and last of a run say so, so the stylesheet can close a
1762
+ * border round the whole thing rather than round every cell.
1763
+ *
1764
+ * @param {Array} placed
1765
+ */
1766
+ markRuns(grid, date, rows) {
1767
+ const together = (a, b) => Boolean(a) && Boolean(b)
1768
+ && a.competition_id === b.competition_id
1769
+ && a.division_id === b.division_id
1770
+ && a.phase === b.phase
1771
+
1772
+ for (const column of grid.columns()) {
1773
+ const placed = column.cells.map(({ element, row }) => ({
1774
+ element,
1775
+ block: row == null ? null : this.blockAt(column.courtId, date, rows[row].key),
1776
+ }))
959
1777
 
960
- rows.forEach((slot, rowIndex) => {
961
- if (slot.start_minutes < coveredUntil) {
962
- return
963
- }
1778
+ // Zebra down the run: a slot hands the court from one group to
1779
+ // the next every turn, and striping those turns is what shows
1780
+ // where each turn starts.
1781
+ let turn = 0
964
1782
 
965
- const element = this.slotElement(court, date, slot, rowIndex, courtIndex, range)
966
- body.appendChild(element)
1783
+ placed.forEach(({ element, block }, index) => {
1784
+ const match = element.querySelector('planning-match')
967
1785
 
968
- const spanEnd = Number(element.dataset.spanEnd)
1786
+ if (!match) {
1787
+ return
1788
+ }
969
1789
 
970
- if (!Number.isNaN(spanEnd)) {
971
- coveredUntil = spanEnd
972
- }
973
- })
1790
+ if (!together(placed[index - 1]?.block, block)) {
1791
+ match.setAttribute('run-start', '')
1792
+ turn = 0
1793
+ }
974
1794
 
975
- column.appendChild(body)
1795
+ if (!together(block, placed[index + 1]?.block)) {
1796
+ match.setAttribute('run-end', '')
1797
+ }
976
1798
 
977
- return column
1799
+ match.style.setProperty('--group-wash', turn % 2 === 0 ? 0 : -WASH_STEP)
1800
+ turn += 1
1801
+ })
1802
+ }
978
1803
  }
979
1804
 
980
1805
  /**
@@ -991,48 +1816,6 @@ export class TennisPlanningBoardElement extends HTMLElementSugar {
991
1816
  return session?.playtime ?? FALLBACK_SLOT_MINUTES
992
1817
  }
993
1818
 
994
- slotElement(court, date, slot, rowIndex, courtIndex, range) {
995
- const element = document.createElement('planning-slot')
996
- const block = this.blockAt(court.id, date, slot.key)
997
- const duration = block
998
- ? this.blockDurationMinutes(block, date)
999
- : slot.end_minutes - slot.start_minutes
1000
-
1001
- element.setAttribute('start', slot.key)
1002
- element.style.top = `${this.pxFor(slot.start_minutes - range.start)}px`
1003
- element.style.height = `${this.pxFor(duration)}px`
1004
-
1005
- if (slot.start_minutes != null) {
1006
- element.setAttribute('duration', duration)
1007
- element.dataset.spanEnd = slot.start_minutes + duration
1008
- }
1009
-
1010
- if (!this.isSelectable(court, slot)) {
1011
- element.setAttribute('blocked', '')
1012
- return element
1013
- }
1014
-
1015
- element.dataset.row = rowIndex
1016
- element.dataset.col = courtIndex
1017
-
1018
- element.addEventListener('mousedown', event => {
1019
- event.preventDefault()
1020
- this.startSelection(date, rowIndex, courtIndex)
1021
- })
1022
-
1023
- element.addEventListener('mouseenter', () => {
1024
- if (this.isSelecting && this.selectionDate === date) {
1025
- this.updateSelection(rowIndex, courtIndex)
1026
- }
1027
- })
1028
-
1029
- if (block) {
1030
- element.appendChild(this.matchElement(block))
1031
- }
1032
-
1033
- return element
1034
- }
1035
-
1036
1819
  /**
1037
1820
  * What is played in a slot. Everything Planning has decided about it
1038
1821
  * is an attribute, so the booking can be read straight off the DOM.