@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.
- package/README.md +2 -0
- package/dist/HemaTournamentUI-lib.es.js +4367 -3850
- package/dist/HemaTournamentUI-lib.umd.js +27 -27
- package/dist/hematournamentui-tailwind.css +267 -9
- package/package.json +1 -1
- package/src/index.js +45 -43
- package/src/stories/Organisms/Cards/RingBoutCard/RingBoutCard.vue +158 -0
- package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.stories.js +10 -0
- package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.vue +140 -80
- package/src/stories/Organisms/Cards/WaitlistFencerCard/WaitlistFencerCard.test.js +94 -0
- package/src/stories/Organisms/Cards/WaitlistFencerCard/WaitlistFencerCard.vue +180 -153
- package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.test.js +19 -3
- package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.vue +59 -22
- package/src/stories/Templates/EventManagement/Bracket/Bracket.stories.js +13 -0
- package/src/stories/Templates/EventManagement/Bracket/Bracket.test.js +26 -0
- package/src/stories/Templates/EventManagement/Bracket/Bracket.vue +6 -2
- package/src/stories/Templates/EventManagement/EventAttendance/EventAttendance.stories.js +35 -3
- package/src/stories/Templates/EventManagement/EventAttendance/EventAttendance.test.js +73 -0
- package/src/stories/Templates/EventManagement/EventAttendance/EventAttendance.vue +417 -403
- package/src/stories/Templates/EventManagement/RingBoard/README.md +29 -0
- package/src/stories/Templates/EventManagement/RingBoard/RingBoard.stories.js +118 -0
- package/src/stories/Templates/EventManagement/RingBoard/RingBoard.test.js +208 -0
- package/src/stories/Templates/EventManagement/RingBoard/RingBoard.vue +170 -0
- package/src/stories/Util/ringBoard.js +103 -0
- package/src/stories/Util/waitlistAttendance.js +8 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# RingBoard
|
|
2
|
+
|
|
3
|
+
Read-only kiosk display for a single event. Uses a white page with black page and ring headers, light-gray bout cards, and dark names. The higher displayed active score is highlighted in orange; tied scores stay neutral. Orange is also used for small active-bout indicators and interrupted updates. The event title, counts, and connection state share a compact header; ring names and directors share a line, and clocks sit alongside bout details. The consuming application selects the event, loads its data, and supplies live updates.
|
|
4
|
+
|
|
5
|
+
```vue
|
|
6
|
+
<RingBoard
|
|
7
|
+
:eventName="event.Name"
|
|
8
|
+
:bouts="bouts"
|
|
9
|
+
:plannedDirectingStations="plannedDirectingStations"
|
|
10
|
+
:plannedBoutAssignments="plannedBoutAssignments"
|
|
11
|
+
:hostingClubColors="hostingClubColors"
|
|
12
|
+
:connectedToServer="connectedToServer"
|
|
13
|
+
/>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Uses the same planned station and assignment shapes as Bracket:
|
|
17
|
+
|
|
18
|
+
- Stations: `{ RingId, RingName, Color?, Director?: { PersonId, DisplayName } }`.
|
|
19
|
+
- Assignments: `{ DEBoutId, RingId }`.
|
|
20
|
+
- Bouts: existing DE bout objects with `Person1`, `Person2`, `Status`, `RoundLabel`, `Score1`, `Score2`, `TimeLeft` (seconds), and `TimerStatus`. Active bouts supply their actual `RingId` or `RingName` and `RefName`.
|
|
21
|
+
- Optional `isCountingBackwardsMaxScore` matches the bracket's score display convention.
|
|
22
|
+
|
|
23
|
+
Scheduled bouts use planned assignments. Active bouts use their actual ring, overriding any old plan; missing actual rings remain unassigned. Active rings outside the current station plan receive a column. Station order stays as supplied. Within each section, bout order stays as supplied; “Scheduled” does not promise a fencing order.
|
|
24
|
+
|
|
25
|
+
Completed bouts and BYEs are omitted. Unresolved matchups are collapsed under “Awaiting fencers”. Unassigned bouts remain visible. Ring availability is described as “No active bout”, since this UI cannot determine whether a ring is otherwise occupied.
|
|
26
|
+
|
|
27
|
+
The board emits no workflow actions and changes no bout data. Running active clocks are a local display estimate from the supplied sample; new samples reconcile the display. Disconnection shows the last supplied time and stops counting. The parent owns clock synchronisation, freshness, socket recovery, and all tournament decisions.
|
|
28
|
+
|
|
29
|
+
Preview: Storybook → Templates / EventManagement / RingBoard. Default shows opening-round traffic; FullTableOf64 also includes the later planned rounds.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import RingBoard from './RingBoard.vue';
|
|
2
|
+
import tableOf64 from '../../../../mocks/getDEBouts-TableOf64.js';
|
|
3
|
+
import { distributeStoryAssignments } from '../Bracket/BracketStoryAssignments.js';
|
|
4
|
+
import { isReadyBout } from '../../../Util/ringBoard.js';
|
|
5
|
+
|
|
6
|
+
const stations = [
|
|
7
|
+
{ RingId: 1, RingName: 'Ring 1', Color: '#375085', Director: { PersonId: 101, DisplayName: 'Bob Morgan' } },
|
|
8
|
+
{ RingId: 2, RingName: 'Ring 2', Color: '#427958', Director: { PersonId: 102, DisplayName: 'Frank Lee' } },
|
|
9
|
+
{ RingId: 3, RingName: 'Ring 3', Color: '#C78544', Director: { PersonId: 103, DisplayName: 'Alex Rivera' } },
|
|
10
|
+
{ RingId: 4, RingName: 'Ring 4', Color: '#4B3A89', Director: { PersonId: 104, DisplayName: 'Jamie Quinn' } }
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const createTableOf64 = () => {
|
|
14
|
+
const bouts = structuredClone(tableOf64.pools[0].Bouts);
|
|
15
|
+
const assignments = distributeStoryAssignments({ bouts, stations });
|
|
16
|
+
const activeIds = new Map();
|
|
17
|
+
for (const station of stations.slice(0, 3)) {
|
|
18
|
+
const assigned = new Set(
|
|
19
|
+
assignments.filter(assignment => assignment.RingId === station.RingId).map(assignment => assignment.DEBoutId)
|
|
20
|
+
);
|
|
21
|
+
const bout = bouts.find(bout => assigned.has(bout.DEBoutId) && isReadyBout(bout));
|
|
22
|
+
if (bout) activeIds.set(bout.DEBoutId, station);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
bouts: bouts.map(bout => {
|
|
26
|
+
const station = activeIds.get(bout.DEBoutId);
|
|
27
|
+
if (!station) return bout;
|
|
28
|
+
return {
|
|
29
|
+
...bout,
|
|
30
|
+
Status: 'Active',
|
|
31
|
+
RingId: station.RingId,
|
|
32
|
+
RingName: station.RingName,
|
|
33
|
+
RefereeId: station.Director.PersonId,
|
|
34
|
+
RefName: station.Director.DisplayName,
|
|
35
|
+
Score1: 6 - station.RingId,
|
|
36
|
+
Score2: 3 + station.RingId,
|
|
37
|
+
TimeLeft: [0, 92, 48, 125][station.RingId],
|
|
38
|
+
TimerStatus: station.RingId === 2 ? 'paused' : 'running'
|
|
39
|
+
};
|
|
40
|
+
}),
|
|
41
|
+
plannedBoutAssignments: assignments,
|
|
42
|
+
plannedDirectingStations: stations
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const fullBoard = createTableOf64();
|
|
47
|
+
const openingRound = {
|
|
48
|
+
...fullBoard,
|
|
49
|
+
bouts: fullBoard.bouts.filter(bout => bout.RoundLabel === 'Table of 64')
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
title: 'Templates/EventManagement/RingBoard',
|
|
54
|
+
component: RingBoard,
|
|
55
|
+
tags: ['autodocs'],
|
|
56
|
+
parameters: {
|
|
57
|
+
layout: 'fullscreen',
|
|
58
|
+
docs: {
|
|
59
|
+
description: {
|
|
60
|
+
component:
|
|
61
|
+
'Compact scoreboard display for a single-event kiosk, with a white page, black headers, and light-gray bout cards. Horizontal headers and inline clocks leave more room for readable fencer names; light-gray cards use orange to highlight the higher displayed active score, with neutral ties. Scheduled bouts use the supplied ring plans; active bouts use their actual ring. Input order is retained within each section and does not promise a fencing queue. Scores and timer samples remain parent-owned.'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
args: {
|
|
66
|
+
eventName: 'Open Longsword · Direct elimination',
|
|
67
|
+
connectedToServer: true,
|
|
68
|
+
hostingClubColors: { Color1: 'blue', Color2: 'red' },
|
|
69
|
+
...openingRound
|
|
70
|
+
},
|
|
71
|
+
argTypes: {
|
|
72
|
+
eventName: { control: 'text' },
|
|
73
|
+
bouts: { control: 'object' },
|
|
74
|
+
plannedDirectingStations: { control: 'object' },
|
|
75
|
+
plannedBoutAssignments: { control: 'object' },
|
|
76
|
+
hostingClubColors: { control: 'object' },
|
|
77
|
+
connectedToServer: { control: 'boolean' },
|
|
78
|
+
isCountingBackwardsMaxScore: { control: 'number' }
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Default = { args: { ...openingRound } };
|
|
83
|
+
export const FullTableOf64 = { args: { ...fullBoard } };
|
|
84
|
+
export const TwoRings = {
|
|
85
|
+
args: {
|
|
86
|
+
plannedDirectingStations: stations.slice(0, 2),
|
|
87
|
+
bouts: openingRound.bouts.filter(bout =>
|
|
88
|
+
openingRound.plannedBoutAssignments.some(
|
|
89
|
+
assignment => assignment.DEBoutId === bout.DEBoutId && assignment.RingId <= 2
|
|
90
|
+
)
|
|
91
|
+
),
|
|
92
|
+
plannedBoutAssignments: openingRound.plannedBoutAssignments.filter(assignment => assignment.RingId <= 2)
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
export const UnassignedBouts = {
|
|
96
|
+
args: {
|
|
97
|
+
...openingRound,
|
|
98
|
+
plannedBoutAssignments: openingRound.plannedBoutAssignments.filter(assignment => assignment.RingId !== 4)
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
export const EmptyRings = { args: { bouts: [], plannedDirectingStations: stations, plannedBoutAssignments: [] } };
|
|
102
|
+
export const NoPlans = { args: { bouts: [], plannedDirectingStations: [], plannedBoutAssignments: [] } };
|
|
103
|
+
export const Disconnected = { args: { ...openingRound, connectedToServer: false } };
|
|
104
|
+
export const LongNames = {
|
|
105
|
+
args: {
|
|
106
|
+
plannedDirectingStations: stations.slice(0, 1),
|
|
107
|
+
plannedBoutAssignments: [{ DEBoutId: 1, RingId: 1 }],
|
|
108
|
+
bouts: [
|
|
109
|
+
{
|
|
110
|
+
DEBoutId: 1,
|
|
111
|
+
Status: 'Scheduled',
|
|
112
|
+
RoundLabel: 'Table of 64',
|
|
113
|
+
Person1: { PersonId: 1, DisplayName: 'Alexandra Montgomery-Worthington' },
|
|
114
|
+
Person2: { PersonId: 2, DisplayName: 'Christopher van der Westhuizen' }
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import RingBoard from './RingBoard.vue';
|
|
4
|
+
import RingBoutCard from '../../../Organisms/Cards/RingBoutCard/RingBoutCard.vue';
|
|
5
|
+
import TableauFencerCard from '../../../Organisms/Cards/TableauFencerCard/TableauFencerCard.vue';
|
|
6
|
+
import { createRingBoardColumns } from '../../../Util/ringBoard.js';
|
|
7
|
+
|
|
8
|
+
const stations = [
|
|
9
|
+
{ RingId: 1, RingName: 'Ring 1', Director: { DisplayName: 'Bob' } },
|
|
10
|
+
{ RingId: 2, RingName: 'Ring 2', Director: { DisplayName: 'Frank' } }
|
|
11
|
+
];
|
|
12
|
+
const createBout = (id, fields = {}) => ({
|
|
13
|
+
DEBoutId: id,
|
|
14
|
+
BoutId: 100 + id,
|
|
15
|
+
Status: 'Scheduled',
|
|
16
|
+
RoundLabel: 'Table of 64',
|
|
17
|
+
Person1: { PersonId: 1, DisplayName: 'Dave Smith' },
|
|
18
|
+
Person2: { PersonId: 2, DisplayName: 'John Wilson' },
|
|
19
|
+
...fields
|
|
20
|
+
});
|
|
21
|
+
const columnsFor = (bouts, assignments = []) => createRingBoardColumns({ bouts, stations, assignments });
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.useRealTimers();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('Ring board grouping', () => {
|
|
28
|
+
it('uses planned rings for scheduled bouts and actual rings for active bouts without mutating inputs', () => {
|
|
29
|
+
const bouts = Object.freeze([
|
|
30
|
+
Object.freeze(createBout(1)),
|
|
31
|
+
Object.freeze(createBout(2, { Status: 'Active', RingName: 'Ring 2' }))
|
|
32
|
+
]);
|
|
33
|
+
const columns = columnsFor(bouts, [
|
|
34
|
+
{ DEBoutId: 1, RingId: '1' },
|
|
35
|
+
{ DEBoutId: 2, RingId: 1 }
|
|
36
|
+
]);
|
|
37
|
+
expect(columns[0].scheduled.map(bout => bout.DEBoutId)).toEqual([1]);
|
|
38
|
+
expect(columns[0].active).toEqual([]);
|
|
39
|
+
expect(columns[1].active.map(bout => bout.DEBoutId)).toEqual([2]);
|
|
40
|
+
expect(bouts[1].Status).toBe('Active');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('preserves ring and supplied bout order and separates unresolved matchups', () => {
|
|
44
|
+
const bouts = [createBout(4), createBout(2), createBout(3, { Person2: null })];
|
|
45
|
+
const columns = columnsFor(
|
|
46
|
+
bouts,
|
|
47
|
+
bouts.map(bout => ({ DEBoutId: bout.DEBoutId, RingId: 1 }))
|
|
48
|
+
);
|
|
49
|
+
expect(columns.map(column => column.ringName)).toEqual(['Ring 1', 'Ring 2']);
|
|
50
|
+
expect(columns[0].scheduled.map(bout => bout.DEBoutId)).toEqual([4, 2]);
|
|
51
|
+
expect(columns[0].awaiting.map(bout => bout.DEBoutId)).toEqual([3]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps unknown plans and active bouts with no actual ring visible as unassigned', () => {
|
|
55
|
+
const columns = columnsFor(
|
|
56
|
+
[createBout(1), createBout(2, { Status: 'Active' })],
|
|
57
|
+
[
|
|
58
|
+
{ DEBoutId: 1, RingId: 999 },
|
|
59
|
+
{ DEBoutId: 2, RingId: 1 }
|
|
60
|
+
]
|
|
61
|
+
);
|
|
62
|
+
expect(columns[2].key).toBe('unassigned');
|
|
63
|
+
expect(columns[2].scheduled).toHaveLength(1);
|
|
64
|
+
expect(columns[2].active).toHaveLength(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('retains active bouts at rings outside the current plan and excludes completed and BYE bouts', () => {
|
|
68
|
+
const columns = columnsFor([
|
|
69
|
+
createBout(1, { Status: 'Active', RingId: 8, RingName: 'Practice ring', RefName: 'Sam' }),
|
|
70
|
+
createBout(2, { Status: 'Completed' }),
|
|
71
|
+
createBout(3, { Status: 'BYE' }),
|
|
72
|
+
createBout(4, { Person2: { PersonId: 0, DisplayName: 'BYE' } }),
|
|
73
|
+
null
|
|
74
|
+
]);
|
|
75
|
+
expect(columns).toHaveLength(3);
|
|
76
|
+
expect(columns[2].ringName).toBe('Practice ring');
|
|
77
|
+
expect(columns[2].active.map(bout => bout.DEBoutId)).toEqual([1]);
|
|
78
|
+
expect(columns[2].directorName).toBe('Sam');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('Ring board data edge cases', () => {
|
|
83
|
+
it('treats BYE placeholders fed by earlier bouts as awaiting fencers', () => {
|
|
84
|
+
const columns = columnsFor(
|
|
85
|
+
[
|
|
86
|
+
createBout(1, { DENextBoutId: 3 }),
|
|
87
|
+
createBout(2, { DENextBoutId: 3 }),
|
|
88
|
+
createBout(3, { Person1: { PersonId: 0, DisplayName: 'BYE' }, Person2: { PersonId: 0, DisplayName: 'BYE' } })
|
|
89
|
+
],
|
|
90
|
+
[{ DEBoutId: 3, RingId: 1 }]
|
|
91
|
+
);
|
|
92
|
+
expect(columns[0].awaiting.map(bout => bout.DEBoutId)).toEqual([3]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('handles null collections and malformed entries without crashing', () => {
|
|
96
|
+
expect(createRingBoardColumns({ bouts: null, stations: null, assignments: null })).toEqual([]);
|
|
97
|
+
expect(createRingBoardColumns({ bouts: [null], stations: [null, {}], assignments: [null, {}] })).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('Ring board presentation', () => {
|
|
102
|
+
it('reconciles parent completion, reassignment, and rollback while remaining read-only', async () => {
|
|
103
|
+
const bout = createBout(1, {
|
|
104
|
+
Status: 'Active',
|
|
105
|
+
RingName: 'Ring 1',
|
|
106
|
+
TimeLeft: 92,
|
|
107
|
+
TimerStatus: 'paused',
|
|
108
|
+
Score1: 6,
|
|
109
|
+
Score2: 4
|
|
110
|
+
});
|
|
111
|
+
const wrapper = mount(RingBoard, {
|
|
112
|
+
props: {
|
|
113
|
+
bouts: [bout],
|
|
114
|
+
plannedDirectingStations: stations,
|
|
115
|
+
connectedToServer: true,
|
|
116
|
+
plannedBoutAssignments: [{ DEBoutId: 1, RingId: 1 }]
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
expect(wrapper.text()).toContain('Dave Smith');
|
|
120
|
+
expect(wrapper.text()).toContain('1:32');
|
|
121
|
+
expect(wrapper.text()).toContain('Paused');
|
|
122
|
+
expect(wrapper.find('button').exists()).toBe(false);
|
|
123
|
+
await wrapper.setProps({ bouts: [{ ...bout, Status: 'Completed' }] });
|
|
124
|
+
expect(wrapper.findAllComponents(RingBoutCard)).toHaveLength(0);
|
|
125
|
+
expect(wrapper.findAll('h2').map(heading => heading.text())).toEqual(['Ring 1', 'Ring 2']);
|
|
126
|
+
await wrapper.setProps({ bouts: [createBout(1)], plannedBoutAssignments: [{ DEBoutId: 1, RingId: 2 }] });
|
|
127
|
+
expect(wrapper.find('[aria-label="Ring 2"]').text()).toContain('Dave Smith');
|
|
128
|
+
expect(wrapper.emitted()).toEqual({});
|
|
129
|
+
wrapper.unmount();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renders empty and disconnected states and expands future matchups', async () => {
|
|
133
|
+
const wrapper = mount(RingBoard);
|
|
134
|
+
expect(wrapper.text()).toContain('No rings planned yet');
|
|
135
|
+
expect(wrapper.text()).toContain('Updates paused');
|
|
136
|
+
await wrapper.setProps({
|
|
137
|
+
bouts: [createBout(1, { Person2: null })],
|
|
138
|
+
plannedDirectingStations: stations,
|
|
139
|
+
plannedBoutAssignments: [{ DEBoutId: 1, RingId: 1 }]
|
|
140
|
+
});
|
|
141
|
+
expect(wrapper.find('summary').text()).toContain('Awaiting fencers');
|
|
142
|
+
expect(wrapper.text()).toContain('Awaiting winner');
|
|
143
|
+
wrapper.unmount();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('Ring bout score highlights', () => {
|
|
148
|
+
it('moves the highlight with live scores and leaves a tie neutral', async () => {
|
|
149
|
+
const bout = createBout(1, { Status: 'Active', Score1: 6, Score2: 4 });
|
|
150
|
+
const wrapper = mount(RingBoutCard, { props: { bout } });
|
|
151
|
+
const scoreColors = () => wrapper.findAllComponents(TableauFencerCard).map(row => row.props('scoreColor'));
|
|
152
|
+
expect(scoreColors()).toEqual(['bg-orange2', 'bg-neutral/50']);
|
|
153
|
+
await wrapper.setProps({ bout: { ...bout, Score1: 6, Score2: 7 } });
|
|
154
|
+
expect(scoreColors()).toEqual(['bg-neutral/50', 'bg-orange2']);
|
|
155
|
+
await wrapper.setProps({ bout: { ...bout, Score1: 7, Score2: 7 } });
|
|
156
|
+
expect(scoreColors()).toEqual(['bg-neutral/50', 'bg-neutral/50']);
|
|
157
|
+
wrapper.unmount();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('highlights the higher displayed score when counting backwards', () => {
|
|
161
|
+
const wrapper = mount(RingBoutCard, {
|
|
162
|
+
props: {
|
|
163
|
+
bout: createBout(1, { Status: 'Active', Score1: 2, Score2: 6 }),
|
|
164
|
+
isCountingBackwardsMaxScore: 10
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
const rows = wrapper.findAllComponents(TableauFencerCard);
|
|
168
|
+
expect(rows.map(row => row.text())).toEqual(['Dave Smith8', 'John Wilson4']);
|
|
169
|
+
expect(rows.map(row => row.props('scoreColor'))).toEqual(['bg-orange2', 'bg-neutral/50']);
|
|
170
|
+
wrapper.unmount();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('Ring bout clocks', () => {
|
|
174
|
+
it('counts down only a running active bout, reconciles a paused sample, and cleans up', async () => {
|
|
175
|
+
vi.useFakeTimers();
|
|
176
|
+
const bout = Object.freeze(createBout(1, { Status: 'Active', TimeLeft: 92, TimerStatus: 'running' }));
|
|
177
|
+
const wrapper = mount(RingBoutCard, { props: { bout } });
|
|
178
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
179
|
+
expect(wrapper.text()).toContain('1:29');
|
|
180
|
+
expect(bout.TimeLeft).toBe(92);
|
|
181
|
+
await wrapper.setProps({ bout: { ...bout, Score1: 7 } });
|
|
182
|
+
expect(wrapper.text()).toContain('1:29');
|
|
183
|
+
await wrapper.setProps({ bout: { ...bout, TimeLeft: 48, TimerStatus: 'paused' } });
|
|
184
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
185
|
+
expect(wrapper.text()).toContain('0:48');
|
|
186
|
+
expect(wrapper.text()).toContain('Paused');
|
|
187
|
+
expect(wrapper.emitted()).toEqual({});
|
|
188
|
+
wrapper.unmount();
|
|
189
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('does not count down disconnected or scheduled bouts and distinguishes missing time from zero', async () => {
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
const bout = createBout(1, { Status: 'Active', TimeLeft: 92, TimerStatus: 'running' });
|
|
195
|
+
const wrapper = mount(RingBoutCard, { props: { bout, connectedToServer: false } });
|
|
196
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
197
|
+
expect(wrapper.text()).toContain('1:32');
|
|
198
|
+
expect(wrapper.text()).toContain('Last received');
|
|
199
|
+
await wrapper.setProps({ connectedToServer: true, bout: { ...bout, TimeLeft: null } });
|
|
200
|
+
expect(wrapper.text()).toContain('Time unavailable');
|
|
201
|
+
await wrapper.setProps({ bout: { ...bout, TimeLeft: 0 } });
|
|
202
|
+
expect(wrapper.text()).toContain('0:00');
|
|
203
|
+
await wrapper.setProps({ bout: createBout(1) });
|
|
204
|
+
expect(wrapper.text()).not.toContain('Remaining');
|
|
205
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
206
|
+
wrapper.unmount();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="min-h-screen w-full bg-neutral font-sans text-primary" aria-label="Event ring board">
|
|
3
|
+
<header
|
|
4
|
+
class="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-neutral/20 bg-black px-4 py-3 text-neutral"
|
|
5
|
+
>
|
|
6
|
+
<h1 class="text-2xl font-bold uppercase tracking-wide">Ring board</h1>
|
|
7
|
+
<p v-if="eventName" class="text-base font-medium text-neutral/75">{{ eventName }}</p>
|
|
8
|
+
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-neutral/65 md:ml-auto">
|
|
9
|
+
<span
|
|
10
|
+
><strong class="text-neutral">{{ ringCount }}</strong> rings</span
|
|
11
|
+
>
|
|
12
|
+
<span
|
|
13
|
+
><strong class="text-neutral">{{ activeCount }}</strong> in progress</span
|
|
14
|
+
>
|
|
15
|
+
<span
|
|
16
|
+
><strong class="text-neutral">{{ scheduledCount }}</strong> scheduled</span
|
|
17
|
+
>
|
|
18
|
+
<span v-if="unassignedCount" class="font-semibold text-neutral">{{ unassignedCount }} unassigned</span>
|
|
19
|
+
<span class="flex items-center gap-2 border-l border-neutral/20 pl-4" :class="connectionClass" role="status">
|
|
20
|
+
<span class="h-1.5 w-1.5 rounded-full" :class="connectedToServer ? 'bg-neutral/60' : 'bg-orange2'"></span>
|
|
21
|
+
{{ connectedToServer ? 'Connected' : 'Updates paused' }}
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<div v-if="!connectedToServer" class="border-b border-lightgray bg-poolBox px-4 py-2 text-sm text-primary/80">
|
|
27
|
+
Showing the last received scores and times. Confirm ring availability before sending fencers.
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div
|
|
31
|
+
v-if="columns.length"
|
|
32
|
+
class="grid grid-cols-1 items-start gap-4 overflow-x-auto p-4 md:grid-flow-col md:auto-cols-[minmax(18rem,1fr)] md:grid-cols-none"
|
|
33
|
+
>
|
|
34
|
+
<section
|
|
35
|
+
v-for="column in columns"
|
|
36
|
+
:key="column.key"
|
|
37
|
+
class="min-w-0 overflow-hidden rounded border border-lightgray bg-neutral"
|
|
38
|
+
:aria-label="column.ringName"
|
|
39
|
+
>
|
|
40
|
+
<header
|
|
41
|
+
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-neutral/20 bg-black px-3 py-3 text-neutral"
|
|
42
|
+
>
|
|
43
|
+
<h2 class="text-2xl font-bold uppercase tracking-tight">{{ column.ringName }}</h2>
|
|
44
|
+
<p class="min-w-0 break-words text-base font-medium text-neutral/75">{{ directorLabel(column) }}</p>
|
|
45
|
+
<span class="rounded px-2 py-1 text-xs font-semibold" :class="ringStateClass(column)">{{
|
|
46
|
+
ringStateLabel(column)
|
|
47
|
+
}}</span>
|
|
48
|
+
</header>
|
|
49
|
+
<div class="space-y-3 p-2">
|
|
50
|
+
<div v-if="column.active.length" class="space-y-2">
|
|
51
|
+
<RingBoutCard
|
|
52
|
+
v-for="bout in column.active"
|
|
53
|
+
:key="bout.DEBoutId ?? bout.BoutId"
|
|
54
|
+
:bout="bout"
|
|
55
|
+
v-bind="cardProps"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
<p
|
|
59
|
+
v-if="!column.active.length && !column.scheduled.length && column.key !== 'unassigned'"
|
|
60
|
+
class="rounded border border-dashed border-lightgray px-3 py-4 text-sm text-primary/60"
|
|
61
|
+
>
|
|
62
|
+
No ready bouts scheduled
|
|
63
|
+
</p>
|
|
64
|
+
<div v-if="column.scheduled.length" class="space-y-2">
|
|
65
|
+
<h3
|
|
66
|
+
class="flex items-center justify-between px-1 text-xs font-semibold uppercase tracking-wider text-primary/60"
|
|
67
|
+
>
|
|
68
|
+
<span>Scheduled</span><span>{{ column.scheduled.length }}</span>
|
|
69
|
+
</h3>
|
|
70
|
+
<RingBoutCard
|
|
71
|
+
v-for="bout in column.scheduled"
|
|
72
|
+
:key="bout.DEBoutId ?? bout.BoutId"
|
|
73
|
+
:bout="bout"
|
|
74
|
+
v-bind="cardProps"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<details v-if="column.awaiting.length" class="rounded border border-lightgray bg-poolBox px-3 py-2">
|
|
78
|
+
<summary class="cursor-pointer text-sm font-medium text-primary/60">
|
|
79
|
+
Awaiting fencers · {{ column.awaiting.length }}
|
|
80
|
+
</summary>
|
|
81
|
+
<div class="mt-2 space-y-2">
|
|
82
|
+
<RingBoutCard
|
|
83
|
+
v-for="bout in column.awaiting"
|
|
84
|
+
:key="bout.DEBoutId ?? bout.BoutId"
|
|
85
|
+
:bout="bout"
|
|
86
|
+
v-bind="cardProps"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</details>
|
|
90
|
+
<p
|
|
91
|
+
v-if="!column.scheduled.length && !column.awaiting.length && column.active.length"
|
|
92
|
+
class="px-1 py-2 text-sm text-primary/60"
|
|
93
|
+
>
|
|
94
|
+
No further bouts scheduled
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
</div>
|
|
99
|
+
<div v-else class="px-4 py-16 text-center">
|
|
100
|
+
<h2 class="text-2xl font-semibold">No rings planned yet</h2>
|
|
101
|
+
<p class="mt-2 text-base text-primary/60">Rings and upcoming bouts will appear when plans are provided.</p>
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
</template>
|
|
105
|
+
|
|
106
|
+
<script>
|
|
107
|
+
import RingBoutCard from '../../../Organisms/Cards/RingBoutCard/RingBoutCard.vue';
|
|
108
|
+
import { createRingBoardColumns } from '../../../Util/ringBoard.js';
|
|
109
|
+
|
|
110
|
+
export default {
|
|
111
|
+
name: 'RingBoard',
|
|
112
|
+
components: { RingBoutCard },
|
|
113
|
+
emits: [],
|
|
114
|
+
props: {
|
|
115
|
+
eventName: { type: String, default: '' },
|
|
116
|
+
bouts: { type: Array, default: () => [] },
|
|
117
|
+
plannedDirectingStations: { type: Array, default: () => [] },
|
|
118
|
+
plannedBoutAssignments: { type: Array, default: () => [] },
|
|
119
|
+
hostingClubColors: { type: Object, default: () => ({}) },
|
|
120
|
+
isCountingBackwardsMaxScore: { type: Number, default: 0 },
|
|
121
|
+
connectedToServer: { type: Boolean, default: false }
|
|
122
|
+
},
|
|
123
|
+
computed: {
|
|
124
|
+
columns() {
|
|
125
|
+
return createRingBoardColumns({
|
|
126
|
+
bouts: this.bouts,
|
|
127
|
+
stations: this.plannedDirectingStations,
|
|
128
|
+
assignments: this.plannedBoutAssignments
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
ringCount() {
|
|
132
|
+
return this.columns.filter(column => column.key !== 'unassigned').length;
|
|
133
|
+
},
|
|
134
|
+
activeCount() {
|
|
135
|
+
return this.columns.reduce((count, column) => count + column.active.length, 0);
|
|
136
|
+
},
|
|
137
|
+
scheduledCount() {
|
|
138
|
+
return this.columns.reduce((count, column) => count + column.scheduled.length + column.awaiting.length, 0);
|
|
139
|
+
},
|
|
140
|
+
unassignedCount() {
|
|
141
|
+
const column = this.columns.find(column => column.key === 'unassigned');
|
|
142
|
+
return column ? column.active.length + column.scheduled.length + column.awaiting.length : 0;
|
|
143
|
+
},
|
|
144
|
+
connectionClass() {
|
|
145
|
+
return this.connectedToServer ? 'text-neutral/60' : 'text-orange2';
|
|
146
|
+
},
|
|
147
|
+
cardProps() {
|
|
148
|
+
return {
|
|
149
|
+
hostingClubColors: this.hostingClubColors,
|
|
150
|
+
isCountingBackwardsMaxScore: this.isCountingBackwardsMaxScore,
|
|
151
|
+
connectedToServer: this.connectedToServer
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
methods: {
|
|
156
|
+
directorLabel(column) {
|
|
157
|
+
const names = [...new Set(column.active.map(bout => bout.RefName).filter(Boolean))];
|
|
158
|
+
return names.join(' / ') || column.directorName;
|
|
159
|
+
},
|
|
160
|
+
ringStateLabel(column) {
|
|
161
|
+
if (column.key === 'unassigned') return 'Needs a ring';
|
|
162
|
+
return column.active.length ? 'In progress' : 'No active bout';
|
|
163
|
+
},
|
|
164
|
+
ringStateClass(column) {
|
|
165
|
+
if (column.key === 'unassigned') return 'border border-neutral/30 text-neutral';
|
|
166
|
+
return column.active.length ? 'bg-neutral/20 text-neutral' : 'border border-neutral/20 text-neutral/60';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
</script>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const getStatus = bout => String(bout?.Status || '').toLowerCase();
|
|
2
|
+
const asArray = value => (Array.isArray(value) ? value : []);
|
|
3
|
+
const hasIdentity = value => value != null && String(value).trim() !== '';
|
|
4
|
+
const getBoutKey = bout => String(bout?.DEBoutId ?? bout?.BoutId ?? '');
|
|
5
|
+
const getRingKey = ring => `ring:${ring.RingId}`;
|
|
6
|
+
const getRingName = ring => ring.RingName || `Ring ${ring.RingId}`;
|
|
7
|
+
|
|
8
|
+
export const isActiveBout = bout => getStatus(bout) === 'active';
|
|
9
|
+
export const hasReadyFencer = person =>
|
|
10
|
+
hasIdentity(person?.PersonId) &&
|
|
11
|
+
Number(person.PersonId) !== 0 &&
|
|
12
|
+
Boolean(String(person.DisplayName || '').trim()) &&
|
|
13
|
+
String(person.DisplayName).trim().toUpperCase() !== 'BYE';
|
|
14
|
+
export const isReadyBout = bout => hasReadyFencer(bout?.Person1) && hasReadyFencer(bout?.Person2);
|
|
15
|
+
const isBye = bout =>
|
|
16
|
+
[bout?.Person1, bout?.Person2].some(
|
|
17
|
+
person =>
|
|
18
|
+
String(person?.DisplayName || '')
|
|
19
|
+
.trim()
|
|
20
|
+
.toUpperCase() === 'BYE'
|
|
21
|
+
);
|
|
22
|
+
const isVisibleBout = (bout, awaitingBoutIds) => {
|
|
23
|
+
if (!bout || !['scheduled', 'active'].includes(getStatus(bout))) return false;
|
|
24
|
+
// Future bracket slots also use BYE placeholders until a feeder bout supplies a fencer.
|
|
25
|
+
return !isBye(bout) || awaitingBoutIds.has(getBoutKey(bout));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const createColumn = station => ({
|
|
29
|
+
key: getRingKey(station),
|
|
30
|
+
ringName: getRingName(station),
|
|
31
|
+
directorName: station.Director?.DisplayName || 'No director listed',
|
|
32
|
+
color: station.Color || '',
|
|
33
|
+
active: [],
|
|
34
|
+
scheduled: [],
|
|
35
|
+
awaiting: []
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const findActualRing = (bout, stations) => {
|
|
39
|
+
if (hasIdentity(bout.RingId)) return stations.find(station => String(station.RingId) === String(bout.RingId));
|
|
40
|
+
if (!bout.RingName) return null;
|
|
41
|
+
return stations.find(station => getRingName(station) === bout.RingName);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getActiveColumn = (bout, stations, columns) => {
|
|
45
|
+
const station = findActualRing(bout, stations);
|
|
46
|
+
if (station) return columns.get(getRingKey(station));
|
|
47
|
+
if (!hasIdentity(bout.RingId) && !bout.RingName) return null;
|
|
48
|
+
if (!hasIdentity(bout.RingId)) {
|
|
49
|
+
const existing = [...columns.values()].find(column => column.ringName === bout.RingName);
|
|
50
|
+
if (existing) return existing;
|
|
51
|
+
}
|
|
52
|
+
const key = hasIdentity(bout.RingId) ? getRingKey(bout) : `name:${bout.RingName}`;
|
|
53
|
+
if (!columns.has(key))
|
|
54
|
+
columns.set(key, {
|
|
55
|
+
...createColumn(bout),
|
|
56
|
+
key,
|
|
57
|
+
directorName: bout.RefName || 'No director listed'
|
|
58
|
+
});
|
|
59
|
+
return columns.get(key);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const addBoutToColumn = (column, bout) => {
|
|
63
|
+
if (isActiveBout(bout)) return column.active.push(bout);
|
|
64
|
+
if (isReadyBout(bout)) return column.scheduled.push(bout);
|
|
65
|
+
column.awaiting.push(bout);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const createRingBoardColumns = ({ bouts, stations, assignments } = {}) => {
|
|
69
|
+
const validBouts = asArray(bouts);
|
|
70
|
+
const validAssignments = asArray(assignments);
|
|
71
|
+
const awaitingBoutIds = new Set(
|
|
72
|
+
validBouts
|
|
73
|
+
.flatMap(bout => [bout?.DENextBoutId, bout?.LoserNextBoutId])
|
|
74
|
+
.filter(hasIdentity)
|
|
75
|
+
.map(String)
|
|
76
|
+
);
|
|
77
|
+
const validStations = asArray(stations).filter(station => hasIdentity(station?.RingId));
|
|
78
|
+
const columns = new Map(validStations.map(station => [getRingKey(station), createColumn(station)]));
|
|
79
|
+
const assignmentByBout = new Map(
|
|
80
|
+
validAssignments
|
|
81
|
+
.filter(assignment => hasIdentity(assignment?.DEBoutId))
|
|
82
|
+
.map(assignment => [String(assignment.DEBoutId), assignment])
|
|
83
|
+
);
|
|
84
|
+
const unassigned = {
|
|
85
|
+
key: 'unassigned',
|
|
86
|
+
ringName: 'Unassigned',
|
|
87
|
+
directorName: 'Ring to be confirmed',
|
|
88
|
+
color: '',
|
|
89
|
+
active: [],
|
|
90
|
+
scheduled: [],
|
|
91
|
+
awaiting: []
|
|
92
|
+
};
|
|
93
|
+
for (const bout of validBouts.filter(bout => isVisibleBout(bout, awaitingBoutIds))) {
|
|
94
|
+
const assignment = assignmentByBout.get(getBoutKey(bout));
|
|
95
|
+
const column = isActiveBout(bout)
|
|
96
|
+
? getActiveColumn(bout, validStations, columns)
|
|
97
|
+
: assignment && columns.get(getRingKey(assignment));
|
|
98
|
+
addBoutToColumn(column || unassigned, bout);
|
|
99
|
+
}
|
|
100
|
+
if (unassigned.active.length + unassigned.scheduled.length + unassigned.awaiting.length)
|
|
101
|
+
columns.set(unassigned.key, unassigned);
|
|
102
|
+
return [...columns.values()];
|
|
103
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const isWaitlistCheckedIn = person => person?.CheckedIn === true;
|
|
2
|
+
|
|
3
|
+
export const needsAttendanceWaiver = person => {
|
|
4
|
+
const waiver = person?.Person?.Weaver;
|
|
5
|
+
if (!waiver?.Required) return false;
|
|
6
|
+
if (String(waiver.Scope ?? 'none').toLowerCase() === 'none') return false;
|
|
7
|
+
return waiver.Signed !== true;
|
|
8
|
+
};
|