@dcrackel/hematournamentui 1.0.707 → 1.0.708

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.
Files changed (46) hide show
  1. package/AGENTS.md +118 -118
  2. package/dist/HemaTournamentUI-lib.es.js +4483 -4618
  3. package/dist/HemaTournamentUI-lib.umd.js +27 -27
  4. package/dist/hematournamentui-tailwind.css +185 -406
  5. package/package.json +10 -10
  6. package/src/index.js +2 -0
  7. package/src/mocks/getDEBouts-42Fencers.js +4 -4
  8. package/src/mocks/getDEBouts-TableOf128.js +37 -0
  9. package/src/mocks/getDEBouts-TableOf64.js +35 -0
  10. package/src/stories/Molecules/Indicators/MissingHemaRatingsId/MissingHemaRatingsId.stories.js +23 -0
  11. package/src/stories/Molecules/Indicators/MissingHemaRatingsId/MissingHemaRatingsId.vue +35 -0
  12. package/src/stories/Molecules/Modals/DirectorModal/DirectorModal.test.js +18 -0
  13. package/src/stories/Molecules/Modals/DirectorModal/DirectorModal.vue +3 -2
  14. package/src/stories/Molecules/Modals/EditBoutModal/EditBoutModal.vue +1 -1
  15. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.stories.js +33 -0
  16. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.test.js +45 -0
  17. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.vue +103 -0
  18. package/src/stories/Organisms/Cards/FencerCard/FencerCard.vue +298 -278
  19. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.stories.js +60 -98
  20. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.test.js +103 -4
  21. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.vue +256 -215
  22. package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.stories.js +69 -92
  23. package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.vue +80 -128
  24. package/src/stories/Organisms/Cards/WaitlistFencerCard/WaitlistFencerCard.vue +14 -4
  25. package/src/stories/Organisms/Containers/Tableau/Tableau.stories.js +2 -2
  26. package/src/stories/Organisms/Containers/Tableau/Tableau.test.js +131 -3
  27. package/src/stories/Organisms/Containers/Tableau/Tableau.vue +287 -89
  28. package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.test.js +29 -0
  29. package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.vue +46 -0
  30. package/src/stories/Organisms/Containers/Tableau/components/TableauSvgOverlay.test.js +21 -0
  31. package/src/stories/Organisms/Containers/Tableau/components/TableauSvgOverlay.vue +39 -0
  32. package/src/stories/Organisms/Containers/Tableau/layout/TableauLayoutEngine.js +145 -0
  33. package/src/stories/Organisms/Containers/Tableau/layout/TableauLayoutEngine.test.js +64 -0
  34. package/src/stories/Organisms/Containers/Tableau/plannedAssignments.js +57 -0
  35. package/src/stories/Organisms/Containers/Tableau/plannedAssignments.test.js +65 -0
  36. package/src/stories/Organisms/Containers/TableauColumn/TableauColumn.vue +31 -29
  37. package/src/stories/Templates/EventManagement/Bracket/Bracket.stories.js +146 -18
  38. package/src/stories/Templates/EventManagement/Bracket/Bracket.test.js +52 -2
  39. package/src/stories/Templates/EventManagement/Bracket/Bracket.vue +53 -17
  40. package/src/stories/Templates/EventManagement/Bracket/BracketPlannedSuggestions.js +39 -0
  41. package/src/stories/Templates/EventManagement/Bracket/BracketStoryHarness.js +39 -0
  42. package/src/stories/Templates/EventManagement/Bracket/BracketStoryHarness.test.js +79 -0
  43. package/src/stories/Organisms/Cards/TableauBoutCard/SpacingConfig.js +0 -111
  44. package/src/stories/Organisms/Containers/TableauLines/SpacingConfig.js +0 -162
  45. package/src/stories/Organisms/Containers/TableauLines/TableauLines.stories.js +0 -21
  46. package/src/stories/Organisms/Containers/TableauLines/TableauLines.vue +0 -78
@@ -1,9 +1,22 @@
1
1
  <template>
2
2
  <section v-if="listView" class="flex flex-col items-center">
3
- <div v-for="(bouts, roundName) in filteredBouts" :key="bouts[0].BoutId" class="w-full flex-col md:w-3/4 my-4">
4
- <BaseText class="my-5" color="primaryHighlight" size="sm" :text="roundName" weight="bold" />
3
+ <div v-for="section in listSections" :key="section.key" class="my-4 w-full flex-col md:w-3/4">
4
+ <BaseText class="my-5" color="primaryHighlight" size="sm" :text="section.label" weight="bold" />
5
5
 
6
- <div v-for="bout in bouts" :key="bout.BoutId" class="mb-4">
6
+ <div v-for="bout in section.bouts" :key="bout.BoutId" class="mb-4">
7
+ <div
8
+ v-if="getPlannedStation(bout)"
9
+ data-testid="planned-bout-label"
10
+ class="mb-1 flex items-center gap-2 rounded border-l-4 border-dropdownSelect bg-white px-2 py-1 shadow-sm"
11
+ :style="{ borderLeftColor: getPlannedStation(bout).Color }"
12
+ >
13
+ <span
14
+ class="h-2.5 w-2.5 flex-none rounded-full"
15
+ :style="{ backgroundColor: getPlannedStation(bout).Color }"
16
+ aria-hidden="true"
17
+ ></span>
18
+ <BaseText :text="getPlannedStationLabel(bout)" color="quaternary" size="xs" />
19
+ </div>
7
20
  <BoutCard
8
21
  :bout="bout"
9
22
  :hostingClubColors="hostingClubColors"
@@ -17,165 +30,350 @@
17
30
  </div>
18
31
  </section>
19
32
 
20
- <section v-else class="flex w-full overflow-x-auto mt-4 scrollbar-thin">
21
- <div class="min-w-full flex">
22
- <div v-for="(bouts, roundName) in groupedBouts.winnerBouts" :key="roundName">
33
+ <section
34
+ v-else
35
+ ref="scrollElement"
36
+ class="mt-4 w-full overflow-x-auto scrollbar-thin"
37
+ @scroll.passive="scheduleConnectorUpdate"
38
+ >
39
+ <div ref="tableauRoot" class="relative min-w-max pb-6">
40
+ <div class="inline-flex items-start">
23
41
  <TableauColumn
24
- :bouts="bouts"
42
+ v-for="([roundName, roundBouts], roundIndex) in winnerRoundEntries"
43
+ :key="`winner-${roundName}`"
44
+ :bouts="roundBouts"
25
45
  :hostingClubColors="hostingClubColors"
26
46
  :roundName="roundName"
27
47
  :bracketSize="bracketSize"
28
- :largeCards="largeCards"
48
+ :layout="getRoundLayout(roundIndex)"
29
49
  :isCountingBackwardsMaxScore="isCountingBackwardsMaxScore"
50
+ @register-node-anchor="registerNodeAnchor"
30
51
  @action:editBout="handleEditBout"
31
52
  @action:directingBout="handleDirectorBout"
32
53
  />
33
54
  </div>
34
- </div>
35
55
 
36
- <!-- Loser bracket -->
37
- <div class="min-w-full flex">
38
- <div v-for="(bouts, roundName) in groupedBouts.loserBouts" :key="roundName">
56
+ <div v-if="placementRoundEntries.length" class="mt-10 flex justify-end pr-10">
39
57
  <TableauColumn
40
- :bouts="bouts"
58
+ v-for="[roundName, roundBouts] in placementRoundEntries"
59
+ :key="`placement-${roundName}`"
60
+ :bouts="roundBouts"
41
61
  :hostingClubColors="hostingClubColors"
42
62
  :roundName="roundName"
43
63
  :bracketSize="bracketSize"
44
- :largeCards="largeCards"
64
+ :layout="placementLayout"
45
65
  :isCountingBackwardsMaxScore="isCountingBackwardsMaxScore"
66
+ @register-node-anchor="registerNodeAnchor"
46
67
  @action:editBout="handleEditBout"
47
68
  @action:directingBout="handleDirectorBout"
48
69
  />
49
70
  </div>
71
+
72
+ <div v-if="loserRoundEntries.length" class="mt-12 border-t border-dropdownSelect pt-6">
73
+ <BaseText class="mb-4" color="quaternary" size="sm" text="Consolation" weight="bold" />
74
+ <div class="inline-flex items-start">
75
+ <TableauColumn
76
+ v-for="([roundName, roundBouts], roundIndex) in loserRoundEntries"
77
+ :key="`loser-${roundName}`"
78
+ :bouts="roundBouts"
79
+ :hostingClubColors="hostingClubColors"
80
+ :roundName="roundName"
81
+ :bracketSize="bracketSize"
82
+ :layout="getRoundLayout(roundIndex)"
83
+ :isCountingBackwardsMaxScore="isCountingBackwardsMaxScore"
84
+ @register-node-anchor="registerNodeAnchor"
85
+ @action:editBout="handleEditBout"
86
+ @action:directingBout="handleDirectorBout"
87
+ />
88
+ </div>
89
+ </div>
90
+
91
+ <TableauSvgOverlay :size="svgSize" :paths="connectorPaths" />
50
92
  </div>
51
93
  </section>
52
94
  </template>
53
95
 
54
96
  <script>
55
- import TableauColumn from '../TableauColumn/TableauColumn.vue';
56
- import TableauBoutCard from '../../Cards/TableauBoutCard/TableauBoutCard.vue';
57
- import BoutCard from '../../../Organisms/Cards/BoutCard/BoutCard.vue';
58
97
  import BaseText from '../../../Atoms/Text/BaseText.vue';
98
+ import BoutCard from '../../../Organisms/Cards/BoutCard/BoutCard.vue';
99
+ import TableauColumn from '../TableauColumn/TableauColumn.vue';
100
+ import TableauSvgOverlay from './components/TableauSvgOverlay.vue';
101
+ import { buildBracketPaths, createDomAnchorLayout } from './layout/TableauLayoutEngine';
102
+ import {
103
+ addPlannedStrokesToConnectors,
104
+ createPlannedStationByBout,
105
+ getPlannedStationForBout,
106
+ isBoutSuggestedForViewer
107
+ } from './plannedAssignments';
59
108
 
60
- const isConsolationRound = ([roundName]) => roundName.includes('Consolation');
61
- const isMainRound = roundEntry => !isConsolationRound(roundEntry);
109
+ const MATCH_HEIGHT_PX = 57;
110
+ const BASE_GAP_PX = 16;
111
+ const BASE_PITCH_PX = MATCH_HEIGHT_PX + BASE_GAP_PX;
112
+ const PLACEMENT_ROUND_NAMES = new Set(['Third Place', 'Third Place Match']);
113
+ const ROUND_ORDER = [
114
+ 'Table of 256',
115
+ 'Table of 128',
116
+ 'Table of 64',
117
+ 'Table of 32',
118
+ 'Table of 16',
119
+ 'Table of 8',
120
+ 'Semifinal',
121
+ 'Final',
122
+ 'Grand Final'
123
+ ];
62
124
 
63
- const roundPriority = {
64
- Final: 1,
65
- 'Third Place Match': 2,
66
- Semifinal: 3
125
+ const roundAliases = {
126
+ Semifinals: 'Semifinal'
67
127
  };
68
128
 
69
- const getRoundNumber = roundName => parseInt(roundName.match(/\d+/)?.[0] || 0, 10);
129
+ const normalizeRoundName = roundName => roundAliases[roundName] || roundName;
130
+ const isConsolationRoundName = roundName => roundName.includes('Consolation');
131
+ const isPlacementRoundName = roundName => PLACEMENT_ROUND_NAMES.has(roundName);
132
+ const getRoundNumber = roundName => Number.parseInt(roundName.match(/\d+/)?.[0] || '0', 10);
133
+ const getBoutNodeId = bout => bout.DEBoutId ?? bout.BoutId;
134
+
135
+ const getRoundOrder = roundName => {
136
+ const mainRoundIndex = ROUND_ORDER.indexOf(normalizeRoundName(roundName));
137
+ if (mainRoundIndex >= 0) return mainRoundIndex;
138
+ if (isConsolationRoundName(roundName)) return ROUND_ORDER.length + getRoundNumber(roundName);
139
+ return ROUND_ORDER.length * 2;
140
+ };
70
141
 
71
- const sortRoundNames = (roundNameA, roundNameB) => {
72
- const priorityA = roundPriority[roundNameA] || 0;
73
- const priorityB = roundPriority[roundNameB] || 0;
142
+ const sortRoundEntries = ([roundNameA], [roundNameB]) => getRoundOrder(roundNameA) - getRoundOrder(roundNameB);
143
+ const sortBoutsByNodeId = (boutA, boutB) => Number(getBoutNodeId(boutA)) - Number(getBoutNodeId(boutB));
74
144
 
75
- if (priorityA || priorityB) return priorityA - priorityB;
76
- return getRoundNumber(roundNameB) - getRoundNumber(roundNameA);
145
+ const addBoutToRound = (rounds, bout) => {
146
+ const roundName = bout.RoundLabel || 'Unknown Round';
147
+ return { ...rounds, [roundName]: [...(rounds[roundName] || []), bout] };
77
148
  };
78
149
 
79
- const isVisibleBout = bout =>
150
+ const groupBoutsByRound = bouts => (bouts || []).reduce(addBoutToRound, {});
151
+
152
+ const isVisibleListBout = bout =>
80
153
  bout.Person1?.DisplayName !== 'BYE' && bout.Person2?.DisplayName !== 'BYE' && bout.Status !== 'Completed';
81
154
 
82
- const createVisibleRoundEntry = ([roundName, bouts]) => {
83
- const visibleBouts = bouts.filter(isVisibleBout).sort((boutA, boutB) => boutA.DEBoutId - boutB.DEBoutId);
84
- return [roundName, visibleBouts];
85
- };
155
+ const createVisibleRoundEntry = ([roundName, bouts]) => [
156
+ roundName,
157
+ bouts.filter(isVisibleListBout).sort(sortBoutsByNodeId)
158
+ ];
86
159
 
87
- const hasVisibleBouts = ([, bouts]) => bouts.length > 0;
160
+ const hasBouts = ([, bouts]) => bouts.length > 0;
88
161
 
89
- const addBoutToRound = (groupedBouts, bout) => {
90
- const roundName = bout.RoundLabel || 'Unknown Round';
91
- const roundBouts = groupedBouts[roundName] || [];
162
+ const createListSection = ([label, bouts]) => ({ key: label, label, bouts });
92
163
 
164
+ const removeSuggestedBouts = (entries, suggestedBoutIds) =>
165
+ entries
166
+ .map(([roundName, bouts]) => [roundName, bouts.filter(bout => !suggestedBoutIds.has(String(bout.BoutId)))])
167
+ .filter(hasBouts);
168
+
169
+ const createRoundLayout = depth => {
170
+ const roundMultiplier = 2 ** Math.max(depth, 0);
93
171
  return {
94
- ...groupedBouts,
95
- [roundName]: [...roundBouts, bout]
172
+ gapPx: BASE_PITCH_PX * roundMultiplier - MATCH_HEIGHT_PX,
173
+ padTopPx: (BASE_PITCH_PX * (roundMultiplier - 1)) / 2
96
174
  };
97
175
  };
98
176
 
99
177
  export default {
100
178
  name: 'Tableau',
101
- components: { BaseText, BoutCard, TableauBoutCard, TableauColumn },
179
+ components: { BaseText, BoutCard, TableauColumn, TableauSvgOverlay },
102
180
  emits: ['action:editBout', 'action:directingBout'],
103
181
  props: {
104
182
  bouts: { type: Array, required: true },
105
183
  bracketSize: { type: String, required: true },
106
184
  hostingClubColors: { type: Object, required: true },
107
- largeCards: { type: Boolean, required: true },
108
- listView: { type: Boolean, required: false },
109
- isCountingBackwardsMaxScore: { type: Number, default: 0 }
185
+ listView: { type: Boolean, default: false },
186
+ isCountingBackwardsMaxScore: { type: Number, default: 0 },
187
+ plannedDirectingStations: { type: Array, default: () => [] },
188
+ plannedBoutAssignments: { type: Array, default: () => [] },
189
+ viewerPersonId: { type: [Number, String], default: null }
190
+ },
191
+ data() {
192
+ return {
193
+ connectorPaths: [],
194
+ inputAnchors: new Map(),
195
+ outputAnchors: new Map(),
196
+ resizeObserver: null,
197
+ scheduledFrame: null,
198
+ svgSize: { width: 0, height: 0 }
199
+ };
110
200
  },
111
201
  computed: {
112
- groupedBouts() {
113
- const grouped = this.groupBoutsByRound(this.bouts);
114
- const roundEntries = Object.entries(grouped);
115
- const winnerBouts = Object.fromEntries(roundEntries.filter(isMainRound));
116
- const loserBouts = Object.fromEntries(roundEntries.filter(isConsolationRound));
117
-
118
- return { winnerBouts, loserBouts };
202
+ roundEntries() {
203
+ return Object.entries(groupBoutsByRound(this.bouts)).map(([roundName, bouts]) => [
204
+ roundName,
205
+ [...bouts].sort(sortBoutsByNodeId)
206
+ ]);
119
207
  },
120
-
121
- combinedBouts() {
122
- const combined = {
123
- ...this.groupedBouts.winnerBouts,
124
- ...this.groupedBouts.loserBouts
125
- };
126
- return Object.fromEntries(
127
- Object.keys(combined)
128
- .sort()
129
- .map(roundName => [roundName, combined[roundName]])
130
- );
208
+ winnerRoundEntries() {
209
+ return this.roundEntries
210
+ .filter(([roundName]) => !isConsolationRoundName(roundName) && !isPlacementRoundName(roundName))
211
+ .sort(sortRoundEntries);
212
+ },
213
+ placementRoundEntries() {
214
+ return this.roundEntries.filter(([roundName]) => isPlacementRoundName(roundName)).sort(sortRoundEntries);
215
+ },
216
+ loserRoundEntries() {
217
+ return this.roundEntries.filter(([roundName]) => isConsolationRoundName(roundName)).sort(sortRoundEntries);
131
218
  },
132
-
133
219
  filteredBouts() {
134
220
  return Object.fromEntries(
135
- Object.entries(this.combinedBouts)
136
- .sort(([roundNameA], [roundNameB]) => sortRoundNames(roundNameA, roundNameB))
137
- .map(createVisibleRoundEntry)
138
- .filter(hasVisibleBouts)
221
+ [...this.roundEntries].sort(sortRoundEntries).map(createVisibleRoundEntry).filter(hasBouts)
139
222
  );
223
+ },
224
+ listSections() {
225
+ const entries = Object.entries(this.filteredBouts);
226
+ const suggestedBouts = entries
227
+ .flatMap(([, bouts]) => bouts)
228
+ .filter(bout =>
229
+ isBoutSuggestedForViewer({
230
+ bout,
231
+ plannedStationByBout: this.plannedStationByBout,
232
+ viewerPersonId: this.viewerPersonId
233
+ })
234
+ );
235
+
236
+ if (!suggestedBouts.length) return entries.map(createListSection);
237
+
238
+ const suggestedBoutIds = new Set(suggestedBouts.map(bout => String(bout.BoutId)));
239
+ const remainingSections = removeSuggestedBouts(entries, suggestedBoutIds).map(createListSection);
240
+ return [{ key: 'suggested-for-viewer', label: 'Suggested for You', bouts: suggestedBouts }, ...remainingSections];
241
+ },
242
+ plannedStationByBout() {
243
+ return createPlannedStationByBout({
244
+ assignments: this.plannedBoutAssignments,
245
+ stations: this.plannedDirectingStations
246
+ });
247
+ },
248
+ placementLayout() {
249
+ return createRoundLayout(0);
250
+ }
251
+ },
252
+ watch: {
253
+ bouts: {
254
+ deep: true,
255
+ handler() {
256
+ this.scheduleConnectorUpdate();
257
+ }
258
+ },
259
+ plannedDirectingStations: {
260
+ deep: true,
261
+ handler() {
262
+ this.scheduleConnectorUpdate();
263
+ }
264
+ },
265
+ plannedBoutAssignments: {
266
+ deep: true,
267
+ handler() {
268
+ this.scheduleConnectorUpdate();
269
+ }
270
+ },
271
+ bracketSize() {
272
+ this.scheduleConnectorUpdate();
273
+ },
274
+ listView(value) {
275
+ if (!value) this.$nextTick(this.observeTableauSize);
276
+ this.scheduleConnectorUpdate();
277
+ }
278
+ },
279
+ mounted() {
280
+ this.observeTableauSize();
281
+ window.addEventListener('resize', this.scheduleConnectorUpdate);
282
+ document.fonts?.ready?.then(this.scheduleConnectorUpdate);
283
+ this.scheduleConnectorUpdate();
284
+ },
285
+ beforeUnmount() {
286
+ this.resizeObserver?.disconnect();
287
+ window.removeEventListener('resize', this.scheduleConnectorUpdate);
288
+ if (this.scheduledFrame !== null && typeof cancelAnimationFrame === 'function') {
289
+ cancelAnimationFrame(this.scheduledFrame);
140
290
  }
141
291
  },
142
-
143
292
  methods: {
144
- // --- helpers ---
145
- _findBoutByIds(idsOrBout) {
146
- // BoutCard emits ids for start/resume/edit; normalize to an actual bout object.
293
+ findBoutByIds(idsOrBout) {
147
294
  if (!idsOrBout) return null;
148
- if (idsOrBout.BoutId && idsOrBout.Person1) return idsOrBout; // already a bout-ish object
295
+ if (idsOrBout.BoutId && idsOrBout.Person1) return idsOrBout;
149
296
 
150
- const boutId = idsOrBout?.BoutId ?? idsOrBout?.boutId;
151
- if (!boutId) return null;
152
- return (this.bouts || []).find(b => String(b.BoutId) === String(boutId)) || null;
297
+ const boutId = idsOrBout.BoutId ?? idsOrBout.boutId;
298
+ return this.bouts.find(bout => String(bout.BoutId) === String(boutId)) || null;
153
299
  },
154
-
155
- // Start/Resume from BoutCard
156
300
  handleDirectingFromIds(ids) {
157
- const bout = this._findBoutByIds(ids);
158
- if (!bout) return;
159
- this.$emit('action:directingBout', bout);
301
+ const bout = this.findBoutByIds(ids);
302
+ if (bout) this.$emit('action:directingBout', bout);
160
303
  },
161
-
162
- // Edit from BoutCard
163
304
  handleEditFromIds(ids) {
164
- const bout = this._findBoutByIds(ids);
165
- if (!bout) return;
166
- this.$emit('action:editBout', bout);
305
+ const bout = this.findBoutByIds(ids);
306
+ if (bout) this.$emit('action:editBout', bout);
167
307
  },
168
-
169
- // TableauColumn / non-list view already emits bout objects
170
308
  handleEditBout(bout) {
171
309
  this.$emit('action:editBout', bout);
172
310
  },
173
311
  handleDirectorBout(bout) {
174
312
  this.$emit('action:directingBout', bout);
175
313
  },
314
+ getRoundLayout(roundIndex) {
315
+ return createRoundLayout(roundIndex);
316
+ },
317
+ getPlannedStation(bout) {
318
+ return getPlannedStationForBout(this.plannedStationByBout, bout);
319
+ },
320
+ getPlannedStationLabel(bout) {
321
+ const station = this.getPlannedStation(bout);
322
+ if (!station) return '';
323
+ const directorName = station.Director?.DisplayName || 'Director TBD';
324
+ return `Suggested: ${station.RingName} / ${directorName}`;
325
+ },
326
+ registerNodeAnchor({ id, kind, el }) {
327
+ const anchors = kind === 'input' ? this.inputAnchors : this.outputAnchors;
328
+ const anchorId = String(id);
329
+
330
+ if (el) anchors.set(anchorId, el);
331
+ if (!el) anchors.delete(anchorId);
332
+ this.scheduleConnectorUpdate();
333
+ },
334
+ observeTableauSize() {
335
+ if (typeof ResizeObserver === 'undefined' || !this.$refs.tableauRoot) return;
336
+ this.resizeObserver?.disconnect();
337
+ this.resizeObserver = new ResizeObserver(this.scheduleConnectorUpdate);
338
+ this.resizeObserver.observe(this.$refs.tableauRoot);
339
+ },
340
+ scheduleConnectorUpdate() {
341
+ if (this.scheduledFrame !== null) return;
342
+ this.scheduledFrame = -1;
343
+
344
+ const update = () => {
345
+ this.scheduledFrame = null;
346
+ this.recomputeConnectors();
347
+ };
348
+
349
+ this.$nextTick(() => {
350
+ if (typeof requestAnimationFrame === 'function') {
351
+ this.scheduledFrame = requestAnimationFrame(update);
352
+ return;
353
+ }
354
+ update();
355
+ });
356
+ },
357
+ recomputeConnectors() {
358
+ const rootElement = this.$refs.tableauRoot;
359
+ if (this.listView || !rootElement) {
360
+ this.connectorPaths = [];
361
+ return;
362
+ }
363
+
364
+ const layout = createDomAnchorLayout({
365
+ rootElement,
366
+ getInputAnchor: id => this.inputAnchors.get(String(id)),
367
+ getOutputAnchor: id => this.outputAnchors.get(String(id))
368
+ });
176
369
 
177
- groupBoutsByRound(bouts) {
178
- return (bouts || []).reduce(addBoutToRound, {});
370
+ this.svgSize = layout.size;
371
+ const paths = buildBracketPaths({
372
+ bouts: this.bouts,
373
+ getInputPoint: layout.getInputPoint,
374
+ getOutputPoint: layout.getOutputPoint
375
+ });
376
+ this.connectorPaths = addPlannedStrokesToConnectors(paths, this.plannedStationByBout);
179
377
  }
180
378
  }
181
379
  };
@@ -0,0 +1,29 @@
1
+ import { shallowMount } from '@vue/test-utils';
2
+ import { describe, expect, it } from 'vitest';
3
+ import BracketDirectorAssignmentCard from '../../../Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.vue';
4
+ import BracketDirectorAssignmentPanel from './BracketDirectorAssignmentPanel.vue';
5
+
6
+ const stations = [
7
+ { RingId: 1, RingName: 'Ring 1', Director: { PersonId: 10, DisplayName: 'Director One' } },
8
+ { RingId: 2, RingName: 'Ring 2', Director: { PersonId: 20, DisplayName: 'Director Two' } }
9
+ ];
10
+
11
+ describe('BracketDirectorAssignmentPanel', () => {
12
+ it('renders assigned directors followed by the add summary card', () => {
13
+ const wrapper = shallowMount(BracketDirectorAssignmentPanel, { props: { stations } });
14
+ const cards = wrapper.findAllComponents(BracketDirectorAssignmentCard);
15
+
16
+ expect(cards).toHaveLength(3);
17
+ expect(cards.slice(0, 2).map(card => card.props('station'))).toEqual(stations);
18
+ expect(cards[2].props('variant')).toBe('add');
19
+ });
20
+
21
+ it('forwards add intent to its parent', async () => {
22
+ const wrapper = shallowMount(BracketDirectorAssignmentPanel, { props: { stations: [] } });
23
+
24
+ wrapper.findComponent(BracketDirectorAssignmentCard).vm.$emit('action:add');
25
+ await wrapper.vm.$nextTick();
26
+
27
+ expect(wrapper.emitted('request:add-planned-director')).toEqual([[]]);
28
+ });
29
+ });
@@ -0,0 +1,46 @@
1
+ <template>
2
+ <aside
3
+ data-testid="bracket-director-assignment-panel"
4
+ class="w-full rounded-xl border border-dropdownSelect bg-bannerBackground p-4 shadow-lg xl:sticky xl:top-4 xl:w-72"
5
+ >
6
+ <div class="mb-4">
7
+ <BaseText color="primaryHighlight" size="md" text="Director Assignments" weight="bold" />
8
+ <BaseText
9
+ class="mt-1"
10
+ color="quaternary"
11
+ size="xs"
12
+ text="Plan ring coverage. Scheduled bouts may still be moved between rings."
13
+ />
14
+ </div>
15
+
16
+ <div class="space-y-3">
17
+ <BracketDirectorAssignmentCard v-for="station in stations" :key="station.RingId" :station="station" />
18
+ <BracketDirectorAssignmentCard
19
+ v-if="showAddCard"
20
+ variant="add"
21
+ @action:add="$emit('request:add-planned-director')"
22
+ />
23
+ </div>
24
+ </aside>
25
+ </template>
26
+
27
+ <script>
28
+ import BaseText from '../../../../Atoms/Text/BaseText.vue';
29
+ import BracketDirectorAssignmentCard from '../../../Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.vue';
30
+
31
+ export default {
32
+ name: 'BracketDirectorAssignmentPanel',
33
+ components: { BaseText, BracketDirectorAssignmentCard },
34
+ emits: ['request:add-planned-director'],
35
+ props: {
36
+ stations: {
37
+ type: Array,
38
+ default: () => []
39
+ },
40
+ showAddCard: {
41
+ type: Boolean,
42
+ default: true
43
+ }
44
+ }
45
+ };
46
+ </script>
@@ -0,0 +1,21 @@
1
+ import { shallowMount } from '@vue/test-utils';
2
+ import { describe, expect, it } from 'vitest';
3
+ import TableauSvgOverlay from './TableauSvgOverlay.vue';
4
+
5
+ describe('TableauSvgOverlay', () => {
6
+ it('uses the standard connector thickness for neutral and assigned paths', () => {
7
+ const wrapper = shallowMount(TableauSvgOverlay, {
8
+ props: {
9
+ size: { width: 200, height: 100 },
10
+ paths: [
11
+ { key: 'neutral', path: 'M 0 0 L 10 0', stroke: null },
12
+ { key: 'assigned', path: 'M 0 10 L 10 10', stroke: '#2563eb' }
13
+ ]
14
+ }
15
+ });
16
+ const paths = wrapper.findAll('path');
17
+
18
+ expect(paths.map(path => path.attributes('stroke-width'))).toEqual(['1.5', '1.5']);
19
+ expect(paths.map(path => path.attributes('stroke'))).toEqual(['currentColor', '#2563eb']);
20
+ });
21
+ });
@@ -0,0 +1,39 @@
1
+ <template>
2
+ <svg
3
+ class="pointer-events-none absolute inset-0 z-0 text-dropdownSelect"
4
+ :width="size.width"
5
+ :height="size.height"
6
+ :viewBox="`0 0 ${size.width} ${size.height}`"
7
+ shape-rendering="crispEdges"
8
+ overflow="visible"
9
+ aria-hidden="true"
10
+ >
11
+ <path
12
+ v-for="connector in paths"
13
+ :key="connector.key"
14
+ :d="connector.path"
15
+ :data-segment-role="connector.segmentRole"
16
+ fill="none"
17
+ :stroke="connector.stroke || 'currentColor'"
18
+ stroke-width="1.5"
19
+ stroke-linecap="square"
20
+ stroke-linejoin="miter"
21
+ />
22
+ </svg>
23
+ </template>
24
+
25
+ <script>
26
+ export default {
27
+ name: 'TableauSvgOverlay',
28
+ props: {
29
+ size: {
30
+ type: Object,
31
+ required: true
32
+ },
33
+ paths: {
34
+ type: Array,
35
+ required: true
36
+ }
37
+ }
38
+ };
39
+ </script>