@dcrackel/hematournamentui 1.0.714 → 1.0.716

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 (25) hide show
  1. package/README.md +2 -0
  2. package/dist/HemaTournamentUI-lib.es.js +4367 -3850
  3. package/dist/HemaTournamentUI-lib.umd.js +27 -27
  4. package/dist/hematournamentui-tailwind.css +267 -9
  5. package/package.json +1 -1
  6. package/src/index.js +45 -43
  7. package/src/stories/Organisms/Cards/RingBoutCard/RingBoutCard.vue +158 -0
  8. package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.stories.js +10 -0
  9. package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.vue +140 -80
  10. package/src/stories/Organisms/Cards/WaitlistFencerCard/WaitlistFencerCard.test.js +94 -0
  11. package/src/stories/Organisms/Cards/WaitlistFencerCard/WaitlistFencerCard.vue +180 -153
  12. package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.test.js +19 -3
  13. package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.vue +59 -22
  14. package/src/stories/Templates/EventManagement/Bracket/Bracket.stories.js +13 -0
  15. package/src/stories/Templates/EventManagement/Bracket/Bracket.test.js +26 -0
  16. package/src/stories/Templates/EventManagement/Bracket/Bracket.vue +6 -2
  17. package/src/stories/Templates/EventManagement/EventAttendance/EventAttendance.stories.js +35 -3
  18. package/src/stories/Templates/EventManagement/EventAttendance/EventAttendance.test.js +73 -0
  19. package/src/stories/Templates/EventManagement/EventAttendance/EventAttendance.vue +417 -403
  20. package/src/stories/Templates/EventManagement/RingBoard/README.md +29 -0
  21. package/src/stories/Templates/EventManagement/RingBoard/RingBoard.stories.js +118 -0
  22. package/src/stories/Templates/EventManagement/RingBoard/RingBoard.test.js +208 -0
  23. package/src/stories/Templates/EventManagement/RingBoard/RingBoard.vue +170 -0
  24. package/src/stories/Util/ringBoard.js +103 -0
  25. package/src/stories/Util/waitlistAttendance.js +8 -0
@@ -0,0 +1,158 @@
1
+ <template>
2
+ <article class="overflow-hidden rounded border" :class="cardClass" :aria-label="boutLabel">
3
+ <header class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-2 py-1.5" :class="headerClass">
4
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
5
+ <span v-if="active || !ready" class="flex items-center gap-1.5 font-semibold text-primary">
6
+ <span v-if="active" class="h-1.5 w-1.5 rounded-full bg-orange2" aria-hidden="true"></span>
7
+ {{ stateLabel }}
8
+ </span>
9
+ <span class="text-primary/70">{{ boutReference }}</span>
10
+ </div>
11
+ <div v-if="active" class="flex flex-wrap items-baseline justify-end gap-x-2">
12
+ <span class="text-xs text-primary/70">{{ timerLabel }}</span>
13
+ <span class="font-mono text-2xl font-bold tabular-nums text-primary">{{ displayTime }}</span>
14
+ </div>
15
+ </header>
16
+ <div class="space-y-px p-1">
17
+ <TableauFencerCard
18
+ v-for="row in fencerRows"
19
+ :key="row.key"
20
+ :fencer="row.fencer"
21
+ :position="row.position"
22
+ :score="row.score"
23
+ :showScore="active"
24
+ :scoreColor="row.scoreColor"
25
+ :color="row.color"
26
+ :isCountingBackwardsMaxScore="isCountingBackwardsMaxScore"
27
+ large
28
+ nightMode
29
+ />
30
+ </div>
31
+ </article>
32
+ </template>
33
+
34
+ <script>
35
+ import TableauFencerCard from '../TableauFencerCard/TableauFencerCard.vue';
36
+ import { hasReadyFencer, isActiveBout, isReadyBout } from '../../../Util/ringBoard.js';
37
+
38
+ const normalizeTime = value => {
39
+ if (value == null || value === '') return null;
40
+ const seconds = Number(value);
41
+ return Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : null;
42
+ };
43
+ const formatTime = seconds =>
44
+ seconds == null ? '—' : `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
45
+
46
+ export default {
47
+ name: 'RingBoutCard',
48
+ components: { TableauFencerCard },
49
+ emits: [],
50
+ props: {
51
+ bout: { type: Object, required: true },
52
+ hostingClubColors: { type: Object, default: () => ({}) },
53
+ isCountingBackwardsMaxScore: { type: Number, default: 0 },
54
+ connectedToServer: { type: Boolean, default: true }
55
+ },
56
+ data() {
57
+ return { now: Date.now(), receivedAt: Date.now(), interval: null };
58
+ },
59
+ computed: {
60
+ active() {
61
+ return isActiveBout(this.bout);
62
+ },
63
+ ready() {
64
+ return isReadyBout(this.bout);
65
+ },
66
+ cardClass() {
67
+ return this.active ? 'border-neutral/60 bg-lightgray' : 'border-neutral/20 bg-lightgray';
68
+ },
69
+ headerClass() {
70
+ return this.active ? 'bg-black/10' : 'bg-black/5';
71
+ },
72
+ stateLabel() {
73
+ if (this.active) return 'In progress';
74
+ return this.ready ? 'Scheduled' : 'Awaiting fencers';
75
+ },
76
+ boutReference() {
77
+ const id = this.bout.DEBoutId ?? this.bout.BoutId;
78
+ return [this.bout.RoundLabel, id == null ? '' : `Bout ${id}`].filter(Boolean).join(' · ');
79
+ },
80
+ boutLabel() {
81
+ return `${this.stateLabel}: ${this.fencerRows.map(row => row.fencer.DisplayName).join(' versus ')}`;
82
+ },
83
+ running() {
84
+ return this.active && String(this.bout.TimerStatus || '').toLowerCase() === 'running';
85
+ },
86
+ remaining() {
87
+ const seconds = normalizeTime(this.bout.TimeLeft);
88
+ if (seconds == null || !this.running || !this.connectedToServer) return seconds;
89
+ return Math.max(0, seconds - Math.floor((this.now - this.receivedAt) / 1000));
90
+ },
91
+ displayTime() {
92
+ return formatTime(this.remaining);
93
+ },
94
+ timerLabel() {
95
+ if (!this.connectedToServer) return 'Last received';
96
+ if (this.remaining == null) return 'Time unavailable';
97
+ if (String(this.bout.TimerStatus || '').toLowerCase() === 'paused') return 'Paused';
98
+ return this.running ? 'Remaining' : 'Stopped';
99
+ },
100
+ fencerRows() {
101
+ return ['1', '2'].map(this.createFencerRow);
102
+ }
103
+ },
104
+ watch: {
105
+ 'bout.TimeLeft': 'resetTimer',
106
+ 'bout.TimerStatus': 'resetTimer',
107
+ 'bout.Status': 'resetTimer',
108
+ 'bout.BoutId': 'resetTimer',
109
+ 'bout.DEBoutId': 'resetTimer',
110
+ connectedToServer: 'resetTimer'
111
+ },
112
+ mounted() {
113
+ this.resetTimer();
114
+ },
115
+ beforeUnmount() {
116
+ this.stopTimer();
117
+ },
118
+ methods: {
119
+ getDisplayedScore(number) {
120
+ const score = Number(this.bout[`Score${number}`] ?? 0);
121
+ if (!Number.isFinite(score)) return null;
122
+ return this.isCountingBackwardsMaxScore > 0 ? this.isCountingBackwardsMaxScore - score : score;
123
+ },
124
+ isLeadingFencer(number) {
125
+ if (!this.active) return false;
126
+ const score = this.getDisplayedScore(number);
127
+ const opponentScore = this.getDisplayedScore(number === '1' ? '2' : '1');
128
+ return score !== null && opponentScore !== null && score > opponentScore;
129
+ },
130
+ createFencerRow(number) {
131
+ const person = this.bout[`Person${number}`];
132
+ return {
133
+ key: number,
134
+ fencer: hasReadyFencer(person) ? person : { DisplayName: 'Awaiting winner' },
135
+ position: person?.EventPersons?.[0]?.DEPosition ?? '',
136
+ score: String(this.bout[`Score${number}`] ?? 0),
137
+ scoreColor: this.isLeadingFencer(number) ? 'bg-orange2' : 'bg-neutral/50',
138
+ color: this.hostingClubColors[`Color${number}`] || ''
139
+ };
140
+ },
141
+ resetTimer() {
142
+ this.stopTimer();
143
+ this.receivedAt = Date.now();
144
+ this.now = this.receivedAt;
145
+ if (!this.running || !this.connectedToServer || !this.remaining) return;
146
+ this.interval = setInterval(this.tickTimer, 250);
147
+ },
148
+ tickTimer() {
149
+ this.now = Date.now();
150
+ if (this.remaining === 0) this.stopTimer();
151
+ },
152
+ stopTimer() {
153
+ if (this.interval) clearInterval(this.interval);
154
+ this.interval = null;
155
+ }
156
+ }
157
+ };
158
+ </script>
@@ -67,3 +67,13 @@ export const PendingFencer = {
67
67
  showScore: false
68
68
  }
69
69
  };
70
+
71
+ export const Large = {
72
+ args: {
73
+ large: true,
74
+ fencer: { DisplayName: 'Alexandra Montgomery-Worthington' },
75
+ score: '6',
76
+ position: '12',
77
+ scoreColor: 'bg-dropdownSelect'
78
+ }
79
+ };
@@ -1,80 +1,140 @@
1
- <template>
2
- <section class="flex h-7 w-40 overflow-hidden border border-poolSetup bg-poolSetup" :class="cornerClass">
3
- <div class="flex w-5 shrink-0 items-center justify-center px-1" :class="positionColorClass">
4
- <BaseText :text="position" size="xs" color="neutral" />
5
- </div>
6
-
7
- <div class="flex min-w-0 flex-1 items-center px-2">
8
- <BaseText :text="name" size="xs" color="quaternary" class="truncate whitespace-nowrap" />
9
- </div>
10
-
11
- <div class="flex w-7 shrink-0 items-center justify-center border-l border-poolSetup" :class="scoreColor">
12
- <BaseText v-if="showScore" :text="displayedScore" size="xs" color="neutral" />
13
- </div>
14
- </section>
15
- </template>
16
-
17
- <script>
18
- import BaseText from '../../../Atoms/Text/BaseText.vue';
19
-
20
- const cornerClasses = {
21
- none: '',
22
- top: 'rounded-t',
23
- bottom: 'rounded-b'
24
- };
25
-
26
- export default {
27
- name: 'TableauFencerCard',
28
- components: { BaseText },
29
- props: {
30
- fencer: {
31
- type: Object,
32
- required: true
33
- },
34
- score: {
35
- type: String,
36
- required: true
37
- },
38
- position: {
39
- type: [String, Number],
40
- required: true
41
- },
42
- color: {
43
- type: String,
44
- default: ''
45
- },
46
- scoreColor: {
47
- type: String,
48
- required: true
49
- },
50
- showScore: {
51
- type: Boolean,
52
- default: true
53
- },
54
- isCountingBackwardsMaxScore: {
55
- type: Number,
56
- default: 0
57
- },
58
- corners: {
59
- type: String,
60
- default: 'none',
61
- validator: value => Object.hasOwn(cornerClasses, value)
62
- }
63
- },
64
- computed: {
65
- cornerClass() {
66
- return cornerClasses[this.corners];
67
- },
68
- displayedScore() {
69
- const score = Number(this.score || 0);
70
- return this.isCountingBackwardsMaxScore > 0 ? this.isCountingBackwardsMaxScore - score : score;
71
- },
72
- name() {
73
- return String(this.fencer?.DisplayName || '');
74
- },
75
- positionColorClass() {
76
- return this.color ? `bg-${this.color}` : 'bg-dropdownSelect';
77
- }
78
- }
79
- };
80
- </script>
1
+ <template>
2
+ <section class="flex overflow-hidden border" :class="[cornerClass, presentation.layout, rowClass]">
3
+ <div
4
+ class="flex shrink-0 items-center justify-center px-1"
5
+ :class="[positionColorClass, presentation.positionWidth]"
6
+ >
7
+ <BaseText :text="position" :size="presentation.positionSize" :color="positionTextColor" />
8
+ </div>
9
+
10
+ <div class="flex min-w-0 flex-1 items-center px-2">
11
+ <BaseText
12
+ :text="name"
13
+ :size="presentation.nameSize"
14
+ :color="presentation.nameColor"
15
+ :class="presentation.nameClass"
16
+ />
17
+ </div>
18
+
19
+ <div
20
+ v-if="showScore || !large"
21
+ class="flex shrink-0 items-center justify-center border-l"
22
+ :class="[scoreColor, presentation.scoreWidth, scoreBorderClass]"
23
+ >
24
+ <BaseText
25
+ v-if="showScore"
26
+ :text="displayedScore"
27
+ :size="presentation.scoreSize"
28
+ :color="presentation.scoreTextColor"
29
+ class="tabular-nums"
30
+ :class="{ 'text-black': nightMode }"
31
+ :weight="nightMode ? 'bold' : 'normal'"
32
+ />
33
+ </div>
34
+ </section>
35
+ </template>
36
+
37
+ <script>
38
+ import BaseText from '../../../Atoms/Text/BaseText.vue';
39
+
40
+ const cornerClasses = {
41
+ none: '',
42
+ top: 'rounded-t',
43
+ bottom: 'rounded-b'
44
+ };
45
+
46
+ export default {
47
+ name: 'TableauFencerCard',
48
+ components: { BaseText },
49
+ props: {
50
+ large: { type: Boolean, default: false },
51
+ nightMode: { type: Boolean, default: false },
52
+ fencer: {
53
+ type: Object,
54
+ required: true
55
+ },
56
+ score: {
57
+ type: String,
58
+ required: true
59
+ },
60
+ position: {
61
+ type: [String, Number],
62
+ required: true
63
+ },
64
+ color: {
65
+ type: String,
66
+ default: ''
67
+ },
68
+ scoreColor: {
69
+ type: String,
70
+ required: true
71
+ },
72
+ showScore: {
73
+ type: Boolean,
74
+ default: true
75
+ },
76
+ isCountingBackwardsMaxScore: {
77
+ type: Number,
78
+ default: 0
79
+ },
80
+ corners: {
81
+ type: String,
82
+ default: 'none',
83
+ validator: value => Object.hasOwn(cornerClasses, value)
84
+ }
85
+ },
86
+ computed: {
87
+ presentation() {
88
+ if (this.large)
89
+ return {
90
+ layout: this.nightMode ? 'min-h-12 w-full' : 'min-h-16 w-full',
91
+ positionWidth: 'w-8',
92
+ positionSize: 'sm',
93
+ nameSize: this.nightMode ? '2xl' : 'xl',
94
+ nameColor: 'primary',
95
+ nameClass: this.nightMode
96
+ ? 'break-words py-2 font-semibold leading-tight'
97
+ : 'break-words py-3 font-semibold leading-tight',
98
+ scoreWidth: 'w-14',
99
+ scoreSize: '3xl',
100
+ scoreTextColor: this.nightMode ? 'none' : 'primary'
101
+ };
102
+ return {
103
+ layout: 'h-7 w-40',
104
+ positionWidth: 'w-5',
105
+ positionSize: 'xs',
106
+ nameSize: 'xs',
107
+ nameColor: this.nightMode ? 'primary' : 'quaternary',
108
+ nameClass: 'truncate whitespace-nowrap',
109
+ scoreWidth: 'w-7',
110
+ scoreSize: 'xs',
111
+ scoreTextColor: this.nightMode ? 'none' : 'neutral'
112
+ };
113
+ },
114
+ scoreBorderClass() {
115
+ return this.nightMode ? 'border-transparent' : 'border-poolSetup';
116
+ },
117
+ rowClass() {
118
+ return this.nightMode ? 'border-black/10 bg-lightgray' : 'border-poolSetup bg-poolSetup';
119
+ },
120
+ positionTextColor() {
121
+ if (this.nightMode) return this.color ? 'neutral' : 'primaryHighlight';
122
+ return this.large && !this.color ? 'primaryHighlight' : 'neutral';
123
+ },
124
+ cornerClass() {
125
+ return cornerClasses[this.corners];
126
+ },
127
+ displayedScore() {
128
+ const score = Number(this.score || 0);
129
+ return this.isCountingBackwardsMaxScore > 0 ? this.isCountingBackwardsMaxScore - score : score;
130
+ },
131
+ name() {
132
+ return String(this.fencer?.DisplayName || '');
133
+ },
134
+ positionColorClass() {
135
+ if (this.color) return `bg-${this.color}`;
136
+ return this.nightMode ? 'bg-black/10' : 'bg-dropdownSelect';
137
+ }
138
+ }
139
+ };
140
+ </script>
@@ -0,0 +1,94 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { describe, expect, it } from 'vitest';
3
+ import WaitlistFencerCard from './WaitlistFencerCard.vue';
4
+ import ButtonBar from '../../../Molecules/Buttons/ButtonBar/ButtonBar.vue';
5
+
6
+ const createPerson = (fields = {}) => ({
7
+ PersonId: 42,
8
+ Status: 'Waitlist',
9
+ CheckedIn: false,
10
+ Person: {
11
+ DisplayName: 'Waiting Fencer',
12
+ Club: { Name: 'Sword Club' },
13
+ Weaver: { Required: true, Scope: 'Tournament', Signed: true }
14
+ },
15
+ ...fields
16
+ });
17
+
18
+ const findButton = (wrapper, label) => wrapper.findAll('button').find(button => button.text() === label);
19
+ const selectAttendance = (wrapper, label) =>
20
+ wrapper
21
+ .getComponent(ButtonBar)
22
+ .findAll('section')
23
+ .find(button => button.text() === label)
24
+ .trigger('click');
25
+
26
+ describe('Waitlist day-of attendance', () => {
27
+ it('marks present and absent without changing waitlist status or mutating incoming data', async () => {
28
+ const person = Object.freeze(createPerson());
29
+ const wrapper = mount(WaitlistFencerCard, { props: { person, queuePosition: 1 } });
30
+
31
+ await selectAttendance(wrapper, 'Present');
32
+ const present = wrapper.emitted('update:check-in')[0][0];
33
+ expect(present).toEqual({ ...person, CheckedIn: true });
34
+ expect(present.Person).not.toBe(person.Person);
35
+ expect(person.CheckedIn).toBe(false);
36
+ expect(wrapper.emitted('move:active')).toBeUndefined();
37
+
38
+ await wrapper.setProps({ person: present });
39
+ expect(wrapper.text()).toContain('Checked in');
40
+ await selectAttendance(wrapper, 'Absent');
41
+ expect(wrapper.emitted('update:check-in')[1][0]).toEqual({ ...person, CheckedIn: false });
42
+ wrapper.unmount();
43
+ });
44
+
45
+ it('offers waiver signing before attendance and keeps promotion available', async () => {
46
+ const person = createPerson();
47
+ person.Person.Weaver.Signed = false;
48
+ const wrapper = mount(WaitlistFencerCard, { props: { person, queuePosition: 1 } });
49
+
50
+ expect(wrapper.findComponent(ButtonBar).exists()).toBe(false);
51
+ await findButton(wrapper, 'Sign Waiver').trigger('click');
52
+ const request = wrapper.emitted('request:weaver-sign')[0][0];
53
+ expect(request).toEqual(person);
54
+ expect(request.Person).not.toBe(person.Person);
55
+ expect(wrapper.emitted('update:check-in')).toBeUndefined();
56
+ await findButton(wrapper, 'Move to Active').trigger('click');
57
+ expect(wrapper.emitted('move:active')[0][0]).toEqual(person);
58
+
59
+ const signed = createPerson();
60
+ await wrapper.setProps({ person: signed });
61
+ expect(findButton(wrapper, 'Sign Waiver')).toBeUndefined();
62
+ expect(wrapper.getComponent(ButtonBar).props('selected')).toBe('Absent');
63
+ await selectAttendance(wrapper, 'Present');
64
+ expect(wrapper.emitted('update:check-in')[0][0].CheckedIn).toBe(true);
65
+ wrapper.unmount();
66
+ });
67
+
68
+ it('reconciles parent attendance and promotes the checked-in waitlist person unchanged', async () => {
69
+ const person = createPerson();
70
+ const wrapper = mount(WaitlistFencerCard, { props: { person, queuePosition: 1, isNextCheckedIn: true } });
71
+
72
+ await wrapper.setProps({ person: { ...person, CheckedIn: true } });
73
+ expect(wrapper.getComponent(ButtonBar).props('selected')).toBe('Present');
74
+ expect(wrapper.text()).toContain('Next checked in');
75
+ await findButton(wrapper, 'Move to Active').trigger('click');
76
+ expect(wrapper.emitted('move:active')[0][0]).toEqual({ ...person, CheckedIn: true });
77
+ expect(wrapper.emitted('move:active')[0][0].Person).not.toBe(person.Person);
78
+
79
+ await wrapper.setProps({ person: { ...person, CheckedIn: false } });
80
+ expect(wrapper.getComponent(ButtonBar).props('selected')).toBe('Absent');
81
+ expect(wrapper.text()).not.toContain('Next checked in');
82
+ wrapper.unmount();
83
+ });
84
+
85
+ it('shows attendance controls when no applicable waiver is required', () => {
86
+ const person = createPerson();
87
+ person.Person.Weaver = { Required: true, Scope: 'none', Signed: false };
88
+ const wrapper = mount(WaitlistFencerCard, { props: { person, queuePosition: 1 } });
89
+
90
+ expect(wrapper.findComponent(ButtonBar).exists()).toBe(true);
91
+ expect(findButton(wrapper, 'Sign Waiver')).toBeUndefined();
92
+ wrapper.unmount();
93
+ });
94
+ });