Skip to content

Commit 32f4e2d

Browse files
committed
add remote control bar
1 parent 7642e05 commit 32f4e2d

10 files changed

Lines changed: 400 additions & 100 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import classNames from 'classnames';
2+
import { Link } from 'react-router-dom';
3+
import { Button } from '@/components/Button';
4+
import { Container } from '@/components/Container';
5+
import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl';
6+
7+
interface NotifyCompRemoteBarProps {
8+
competitionId: string;
9+
}
10+
11+
const groupLabel = (count: number) => `${count} active activit${count === 1 ? 'y' : 'ies'}`;
12+
13+
export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) {
14+
const remote = useCompetitionRemoteControl({ competitionId });
15+
16+
if (!remote.isAuthenticated || remote.scheduledActivities.length === 0) {
17+
return null;
18+
}
19+
20+
const activeNames = remote.activeGroups.map((group) => group.name);
21+
const title = activeNames.length > 0 ? activeNames.join(', ') : 'No active activities';
22+
const detail =
23+
activeNames.length > 0
24+
? groupLabel(activeNames.length)
25+
: remote.nextGroup
26+
? `Next: ${remote.nextGroup.name}`
27+
: 'Remote overview';
28+
29+
const runSwitch = (direction: 'previous' | 'next') => {
30+
const group = direction === 'previous' ? remote.previousGroup : remote.nextGroup;
31+
void remote.switchToGroup(group);
32+
};
33+
34+
return (
35+
<nav
36+
aria-label="Remote control"
37+
className="z-20 w-full border-t border-tertiary-weak bg-panel shadow-md shadow-tertiary-dark print:hidden">
38+
<Container className="flex-row items-center gap-2 px-2 py-2">
39+
<Button
40+
type="button"
41+
variant="light"
42+
className="min-w-[76px] justify-center"
43+
disabled={remote.isSaving || !remote.previousGroup}
44+
onClick={() => runSwitch('previous')}>
45+
Back
46+
</Button>
47+
48+
<Link
49+
to={`/competitions/${competitionId}/remote`}
50+
className={classNames(
51+
'min-w-0 flex-1 rounded border border-tertiary-weak px-3 py-2 hover-transition hover:bg-gray-100 dark:hover:bg-gray-700',
52+
{
53+
'opacity-60': remote.isLoading,
54+
},
55+
)}>
56+
<div className="flex min-w-0 flex-col">
57+
<span className="truncate type-label">{title}</span>
58+
<span className="truncate type-meta">
59+
{remote.error ? `Remote error: ${remote.error}` : detail}
60+
</span>
61+
</div>
62+
</Link>
63+
64+
<Button
65+
type="button"
66+
variant="green"
67+
className="min-w-[76px] justify-center"
68+
disabled={remote.isSaving || !remote.nextGroup}
69+
onClick={() => runSwitch('next')}>
70+
Next
71+
</Button>
72+
</Container>
73+
</nav>
74+
);
75+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './NotifyCompRemoteBar';

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export * from './LastFetchedAt';
1616
export * from './LinkButton';
1717
export * from './LoggedOutPromptCard';
1818
export * from './Notebox';
19+
export * from './NotifyCompRemoteBar';
1920
export * from './PinCompetitionButton';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useCompetitionRemoteControl';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { useMemo } from 'react';
2+
import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities';
3+
import {
4+
getRemoteActiveGroups,
5+
getRemoteActivityGroups,
6+
getRemoteActivityStates,
7+
getRemoteNextGroup,
8+
getRemotePreviousGroup,
9+
getRemoteScheduledActivities,
10+
RemoteActivityGroup,
11+
} from '@/lib/notifyCompRemoteActivities';
12+
import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider';
13+
import { useWCIF } from '@/providers/WCIFProvider';
14+
15+
interface UseCompetitionRemoteControlParams {
16+
competitionId: string;
17+
enabled?: boolean;
18+
roomId?: number;
19+
}
20+
21+
const activityIdsForGroup = (group: RemoteActivityGroup) =>
22+
group.scheduledActivities.map((activity) => activity.id);
23+
24+
export function useCompetitionRemoteControl({
25+
competitionId,
26+
enabled = true,
27+
roomId,
28+
}: UseCompetitionRemoteControlParams) {
29+
const { wcif } = useWCIF();
30+
const remoteAuth = useNotifyCompRemoteAuth();
31+
const isAuthenticated = remoteAuth.isAuthenticatedForCompetition(competitionId);
32+
const isEnabled = enabled && isAuthenticated;
33+
34+
const remote = useNotifyCompRemoteActivities({
35+
competitionId,
36+
enabled: isEnabled,
37+
roomId,
38+
});
39+
40+
const scheduledActivities = useMemo(
41+
() => (wcif ? getRemoteScheduledActivities(wcif, roomId) : []),
42+
[roomId, wcif],
43+
);
44+
45+
const activityStates = useMemo(
46+
() => getRemoteActivityStates(scheduledActivities, remote.activities),
47+
[remote.activities, scheduledActivities],
48+
);
49+
50+
const activityGroups = useMemo(
51+
() => getRemoteActivityGroups(scheduledActivities, remote.activities),
52+
[remote.activities, scheduledActivities],
53+
);
54+
55+
const activeGroups = useMemo(() => getRemoteActiveGroups(activityGroups), [activityGroups]);
56+
const previousGroup = useMemo(() => getRemotePreviousGroup(activityGroups), [activityGroups]);
57+
const nextGroup = useMemo(() => getRemoteNextGroup(activityGroups), [activityGroups]);
58+
59+
const startGroup = (group: RemoteActivityGroup) =>
60+
remote.startActivities(activityIdsForGroup(group));
61+
const stopGroup = (group: RemoteActivityGroup) =>
62+
remote.stopActivities(activityIdsForGroup(group));
63+
const resetGroup = (group: RemoteActivityGroup) =>
64+
remote.resetActivities(activityIdsForGroup(group));
65+
66+
const switchToGroup = async (group?: RemoteActivityGroup) => {
67+
if (!group) {
68+
return;
69+
}
70+
71+
const currentActivityIds = activeGroups.flatMap(activityIdsForGroup);
72+
73+
if (currentActivityIds.length > 0) {
74+
await remote.stopActivities(currentActivityIds);
75+
}
76+
77+
await remote.startActivities(activityIdsForGroup(group));
78+
};
79+
80+
return {
81+
...remote,
82+
activeGroups,
83+
activityGroups,
84+
activityStates,
85+
isAuthenticated,
86+
nextGroup,
87+
previousGroup,
88+
resetGroup,
89+
scheduledActivities,
90+
startGroup,
91+
stopGroup,
92+
switchToGroup,
93+
};
94+
}

src/layouts/CompetitionLayout/CompetitionLayout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react';
33
import { ErrorBoundary } from 'react-error-boundary';
44
import { Outlet, useLocation, useParams } from 'react-router-dom';
55
import { BarLoader } from 'react-spinners';
6-
import { ErrorFallback, LastFetchedAt, NoteBox } from '@/components';
6+
import { ErrorFallback, LastFetchedAt, NoteBox, NotifyCompRemoteBar } from '@/components';
77
import { Container } from '@/components/Container';
88
import { StyledNavLink } from '@/components/StyledNavLink/StyledNavLink';
99
import { useWcif } from '@/hooks/queries/useWcif';
@@ -65,7 +65,7 @@ export function CompetitionLayout() {
6565
)}
6666
{isFetching ? <BarLoader width="100%" /> : <div style={{ height: '4px' }} />}
6767
<div
68-
className="flex flex-col w-full items-center overflow-y-auto [scrollbar-gutter:stable;]"
68+
className="flex flex-1 flex-col w-full items-center overflow-y-auto [scrollbar-gutter:stable;]"
6969
ref={ref}>
7070
<ErrorBoundary FallbackComponent={ErrorFallback}>
7171
<Outlet />
@@ -80,6 +80,7 @@ export function CompetitionLayout() {
8080
</Container>
8181
)}
8282
</div>
83+
{competitionId && <NotifyCompRemoteBar competitionId={competitionId} />}
8384
</div>
8485
</WCIFProvider>
8586
);

src/lib/notifyCompRemoteActivities.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
getRemoteActivityGroups,
33
getRemoteActivityState,
4+
getRemoteNextGroup,
5+
getRemotePreviousGroup,
46
RemoteScheduledActivity,
57
} from './notifyCompRemoteActivities';
68

@@ -71,4 +73,48 @@ describe('notifyCompRemoteActivities', () => {
7173
expect(groups).toHaveLength(1);
7274
expect(groups[0].scheduledActivities.map((candidate) => candidate.id)).toEqual([201, 202]);
7375
});
76+
77+
it('finds the next group after the current activity group', () => {
78+
const groups = getRemoteActivityGroups(
79+
[
80+
activity({ id: 301, activityCode: '333-r1-g1' }),
81+
activity({
82+
id: 302,
83+
activityCode: '222-r1-g1',
84+
name: '2x2x2 Cube Round 1 Group 1',
85+
startTime: '2026-06-01T10:20:00Z',
86+
}),
87+
],
88+
[{ activityId: 301, startTime: '2026-06-01T10:01:00Z', endTime: null }],
89+
);
90+
91+
expect(getRemoteNextGroup(groups)?.scheduledActivities[0].id).toBe(302);
92+
});
93+
94+
it('finds the previous group before the current activity group', () => {
95+
const groups = getRemoteActivityGroups(
96+
[
97+
activity({
98+
id: 401,
99+
activityCode: '222-r1-g1',
100+
name: '2x2x2 Cube Round 1 Group 1',
101+
}),
102+
activity({
103+
id: 402,
104+
activityCode: '333-r1-g1',
105+
startTime: '2026-06-01T10:20:00Z',
106+
}),
107+
],
108+
[
109+
{
110+
activityId: 401,
111+
startTime: '2026-06-01T10:01:00Z',
112+
endTime: '2026-06-01T10:09:00Z',
113+
},
114+
{ activityId: 402, startTime: '2026-06-01T10:21:00Z', endTime: null },
115+
],
116+
);
117+
118+
expect(getRemotePreviousGroup(groups)?.scheduledActivities[0].id).toBe(401);
119+
});
74120
});

src/lib/notifyCompRemoteActivities.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,61 @@ export const getRemoteActivityGroups = (
112112
});
113113
};
114114

115+
export const splitRemoteActivityGroups = (groups: RemoteActivityGroup[]) => ({
116+
current: groups.filter((group) => group.status === 'current' || group.status === 'mixed'),
117+
next: groups.filter((group) => group.status === 'next'),
118+
done: groups.filter((group) => group.status === 'done'),
119+
});
120+
121+
export const getRemoteActiveGroups = (groups: RemoteActivityGroup[]) =>
122+
groups.filter((group) => group.status === 'current' || group.status === 'mixed');
123+
124+
export const getRemoteNavigationFocusIndex = (groups: RemoteActivityGroup[]) => {
125+
const activeIndex = groups.findIndex(
126+
(group) => group.status === 'current' || group.status === 'mixed',
127+
);
128+
129+
if (activeIndex >= 0) {
130+
return activeIndex;
131+
}
132+
133+
const nextIndex = groups.findIndex((group) => group.status === 'next');
134+
135+
return nextIndex >= 0 ? nextIndex : groups.length - 1;
136+
};
137+
138+
export const getRemotePreviousGroup = (groups: RemoteActivityGroup[]) => {
139+
const focusIndex = getRemoteNavigationFocusIndex(groups);
140+
141+
if (focusIndex <= 0) {
142+
return undefined;
143+
}
144+
145+
return [...groups.slice(0, focusIndex)]
146+
.reverse()
147+
.find((group) => group.status !== 'current' && group.status !== 'mixed');
148+
};
149+
150+
export const getRemoteNextGroup = (groups: RemoteActivityGroup[]) => {
151+
const activeIndex = groups.findIndex(
152+
(group) => group.status === 'current' || group.status === 'mixed',
153+
);
154+
155+
if (activeIndex < 0) {
156+
return groups.find((group) => group.status === 'next');
157+
}
158+
159+
const focusIndex = getRemoteNavigationFocusIndex(groups);
160+
161+
if (focusIndex < 0) {
162+
return groups.find((group) => group.status === 'next');
163+
}
164+
165+
return groups
166+
.slice(focusIndex + 1)
167+
.find((group) => group.status !== 'done' && group.status !== 'current');
168+
};
169+
115170
export const splitRemoteActivityStates = (states: RemoteActivityState[]) => ({
116171
current: states.filter((state) => state.status === 'current'),
117172
next: states.filter((state) => state.status === 'next'),

0 commit comments

Comments
 (0)