diff --git a/.gitignore b/.gitignore index fb7a4a0..6d34c38 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ yarn-error.log* storybook-static .cache +.netlify diff --git a/netlify/functions/notify-comp-token.js b/netlify/functions/notify-comp-token.js index d63bf79..2dd207a 100644 --- a/netlify/functions/notify-comp-token.js +++ b/netlify/functions/notify-comp-token.js @@ -8,6 +8,24 @@ const headers = { }; const base64Url = (value) => Buffer.from(value).toString('base64url'); +const REMOTE_SCOPE = 'notifycomp.remote'; +const PUSH_SCOPE = 'assignment_notifications'; +const REMOTE_TOKEN_TTL_SECONDS = 12 * 60 * 60; +const PUSH_TOKEN_TTL_SECONDS = 10 * 60; + +const getCompetitionManagers = (competition) => { + const data = competition?.competition || competition; + return [...(data?.organizers || []), ...(data?.delegates || [])]; +}; + +const isListedCompetitionManager = (competition, userId) => + getCompetitionManagers(competition).some((user) => Number(user?.id) === Number(userId)); + +const getManagedCompetitionIds = (competitions, userId) => + competitions + .filter((competition) => isListedCompetitionManager(competition, userId)) + .map((competition) => competition.id) + .filter(Boolean); const signJwt = (claims, secret) => { const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); @@ -42,7 +60,19 @@ exports.handler = async (event) => { }; } - const { accessToken } = JSON.parse(event.body || '{}'); + let body; + try { + body = JSON.parse(event.body || '{}'); + } catch { + return { + statusCode: 400, + headers, + body: JSON.stringify({ message: 'Invalid JSON body' }), + }; + } + + const { accessToken, competitionId, scope } = body; + const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE; if (!accessToken) { return { statusCode: 400, @@ -52,7 +82,9 @@ exports.handler = async (event) => { } const wcaOrigin = process.env.WCA_ORIGIN || 'https://www.worldcubeassociation.org'; - const meResponse = await fetch(`${wcaOrigin}/api/v0/me`, { + const meParams = + tokenScope === REMOTE_SCOPE ? '?upcoming_competitions=true&ongoing_competitions=true' : ''; + const meResponse = await fetch(`${wcaOrigin}/api/v0/me${meParams}`, { headers: { Authorization: `Bearer ${accessToken}`, }, @@ -66,7 +98,7 @@ exports.handler = async (event) => { }; } - const { me } = await meResponse.json(); + const { me, ongoing_competitions = [], upcoming_competitions = [] } = await meResponse.json(); if (!me?.id) { return { statusCode: 401, @@ -75,14 +107,68 @@ exports.handler = async (event) => { }; } + const tokenTtlSeconds = + tokenScope === REMOTE_SCOPE ? REMOTE_TOKEN_TTL_SECONDS : PUSH_TOKEN_TTL_SECONDS; + if (tokenScope === REMOTE_SCOPE && !competitionId) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ message: 'Missing competition ID for remote token' }), + }; + } + + let remoteCompetitionIds = []; + + if (tokenScope === REMOTE_SCOPE) { + const competitionResponse = await fetch(`${wcaOrigin}/api/v0/competitions/${competitionId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!competitionResponse.ok) { + return { + statusCode: competitionResponse.status === 404 ? 404 : 502, + headers, + body: JSON.stringify({ message: 'Unable to verify competition remote access' }), + }; + } + + const competition = await competitionResponse.json(); + if (!isListedCompetitionManager(competition, me.id)) { + return { + statusCode: 403, + headers, + body: JSON.stringify({ + message: + 'Only listed competition delegates and organizers can use Live Activities Remote', + }), + }; + } + + // Remote tokens are scoped to competitions the WCA API says this user manages. The + // NotifyComp API must reject remote mutations for competition IDs outside this claim. + remoteCompetitionIds = [ + ...new Set([ + competitionId, + ...getManagedCompetitionIds([...ongoing_competitions, ...upcoming_competitions], me.id), + ]), + ]; + } + const now = Math.floor(Date.now() / 1000); const token = signJwt( { aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp', - exp: now + 10 * 60, + competitionIds: tokenScope === REMOTE_SCOPE ? remoteCompetitionIds : undefined, + exp: now + tokenTtlSeconds, iat: now, iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com', + name: me.name, + scope: tokenScope, + scopes: [tokenScope], sub: `wca:${me.id}`, + wcaUserId: me.id, wcaUserIds: [me.id], }, secret, diff --git a/src/App.tsx b/src/App.tsx index a2df4af..abaafe2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import CompetitionLive from './pages/Competition/Live'; import CompetitionPerson from './pages/Competition/Person'; import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests'; import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent'; +import CompetitionRemote from './pages/Competition/Remote'; import CompetitionResults from './pages/Competition/Results'; import { CompetitionActivity, @@ -30,12 +31,15 @@ import CompetitionStats from './pages/Competition/Stats'; import CompetitionStreamSchedule from './pages/Competition/StreamSchedule'; import CompetitionSumOfRanks from './pages/Competition/SumOfRanks'; import Home from './pages/Home'; +import LiveActivitiesAbout from './pages/LiveActivities/About'; import Settings from './pages/Settings'; import Support from './pages/Support'; import Test from './pages/Test'; import UserLogin from './pages/UserLogin'; import { AppProvider } from './providers/AppProvider'; import { AuthProvider, useAuth } from './providers/AuthProvider'; +import { ConfirmProvider } from './providers/ConfirmProvider'; +import { NotifyCompRemoteAuthProvider } from './providers/NotifyCompRemoteAuthProvider'; import { QueryProvider } from './providers/QueryProvider/QueryProvider'; import { UserSettingsProvider } from './providers/UserSettingsProvider'; import { useWCIF } from './providers/WCIFProvider'; @@ -108,6 +112,7 @@ const Navigation = () => { } /> } /> + } /> } /> } /> @@ -126,6 +131,7 @@ const Navigation = () => { } /> } /> + } /> } /> } /> @@ -141,9 +147,13 @@ const App = () => ( - - - + + + + + + + diff --git a/src/apolloClient.ts b/src/apolloClient.ts index a8b84e8..4e33106 100644 --- a/src/apolloClient.ts +++ b/src/apolloClient.ts @@ -1,25 +1,58 @@ import { ApolloClient, createHttpLink, InMemoryCache, split } from '@apollo/client'; +import { setContext } from '@apollo/client/link/context'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { getMainDefinition } from '@apollo/client/utilities'; import { createClient } from 'graphql-ws'; +import { getNotifyCompRemoteToken } from './lib/notifyCompRemoteAuth'; +import { setNotifyCompWebSocketStatus } from './lib/notifyCompWebSocketStatus'; +import { NOTIFYCOMP_GRAPHQL_ORIGIN, NOTIFYCOMP_WS_ORIGIN } from './lib/remoteConfig'; const httpLink = createHttpLink({ - uri: import.meta.env.VITE_NOTIFYCOMP_API_ORIGIN || 'https://api.notifycomp.com/api/graphql', + uri: NOTIFYCOMP_GRAPHQL_ORIGIN, }); const wsLink = new GraphQLWsLink( createClient({ - url: import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://api.notifycomp.com/api/graphql', + url: NOTIFYCOMP_WS_ORIGIN, + on: { + connecting: () => { + setNotifyCompWebSocketStatus({ status: 'connecting' }); + }, + connected: () => { + setNotifyCompWebSocketStatus({ status: 'connected' }); + }, + closed: () => { + setNotifyCompWebSocketStatus({ status: 'disconnected' }); + }, + error: () => { + setNotifyCompWebSocketStatus({ + status: 'disconnected', + message: + 'Unable to connect to NotifyComp live updates. Activity changes may not update automatically.', + }); + }, + }, }), ); +const authLink = setContext((_, { headers }) => { + const token = getNotifyCompRemoteToken(); + + return { + headers: { + ...headers, + ...(token && { authorization: `Bearer ${token}` }), + }, + }; +}); + const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'; }, wsLink, - httpLink, + authLink.concat(httpLink), ); const client = new ApolloClient({ diff --git a/src/components/NotifyCompConnectionStatus/NotifyCompConnectionStatus.tsx b/src/components/NotifyCompConnectionStatus/NotifyCompConnectionStatus.tsx new file mode 100644 index 0000000..59b160e --- /dev/null +++ b/src/components/NotifyCompConnectionStatus/NotifyCompConnectionStatus.tsx @@ -0,0 +1,47 @@ +import classNames from 'classnames'; +import { NoteBox } from '@/components/Notebox'; +import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus'; + +interface NotifyCompConnectionStatusProps { + className?: string; + compact?: boolean; +} + +const statusText = { + connecting: 'Connecting to NotifyComp live updates...', + disconnected: + 'Not connected to NotifyComp live updates. Activity changes may not update automatically.', + idle: '', + connected: '', +}; + +export function NotifyCompConnectionStatus({ + className, + compact = false, +}: NotifyCompConnectionStatusProps) { + const { message, status } = useNotifyCompWebSocketStatus(); + + if (status === 'idle' || status === 'connected') { + return null; + } + + const text = message || statusText[status]; + const toneClassName = + status === 'connecting' ? 'bg-yellow-400 dark:bg-yellow-300' : 'bg-red-500 dark:bg-red-400'; + + if (compact) { + return ( +
+
+ ); + } + + return ; +} diff --git a/src/components/NotifyCompConnectionStatus/index.ts b/src/components/NotifyCompConnectionStatus/index.ts new file mode 100644 index 0000000..a239111 --- /dev/null +++ b/src/components/NotifyCompConnectionStatus/index.ts @@ -0,0 +1 @@ +export * from './NotifyCompConnectionStatus'; diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx new file mode 100644 index 0000000..5611ba6 --- /dev/null +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -0,0 +1,208 @@ +import classNames from 'classnames'; +import { Container } from '@/components/Container'; +import { NotifyCompConnectionStatus } from '@/components/NotifyCompConnectionStatus'; +import { RemoteActivitySummaryList } from '@/components/RemoteActivitySummaryList'; +import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; +import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus'; +import { useNow } from '@/hooks/useNow'; +import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities'; +import { canUseNotifyCompRemoteControls } from '@/lib/notifyCompWebSocketStatus'; +import { useConfirm } from '@/providers/ConfirmProvider'; +import { + formatElapsedDuration, + formatNextActivityOffset, + getRemoteBarProgress, +} from './remoteBarProgress'; + +interface NotifyCompRemoteBarProps { + competitionId: string; +} + +const iconButtonClassName = + 'flex h-10 w-10 items-center justify-center rounded-full border border-tertiary-weak bg-panel text-lg leading-none text-default shadow-sm hover-transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-700'; +const primaryButtonClassName = + 'flex h-10 w-10 items-center justify-center rounded-full border border-blue-300 bg-blue-200 text-lg leading-none text-gray-900 shadow-sm hover-transition hover:bg-blue-300 disabled:cursor-not-allowed disabled:opacity-40 dark:border-blue-600 dark:bg-blue-700 dark:text-white dark:hover:bg-blue-600'; + +export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) { + const confirm = useConfirm(); + const remote = useCompetitionRemoteControl({ competitionId }); + const notifyCompWebSocketStatus = useNotifyCompWebSocketStatus(); + const now = useNow(); + + if (!remote.isAuthenticated || !remote.competition || remote.scheduledActivities.length === 0) { + return null; + } + + const activeNames = remote.activeGroups.map((group) => group.name); + const hasActiveGroups = activeNames.length > 0; + const currentTitle = activeNames.length > 0 ? activeNames.join(', ') : 'No active activity'; + const nextTitle = remote.nextGroup?.name || 'No next activity'; + const elapsed = formatElapsedDuration(remote.activeGroups, now); + const nextOffset = formatNextActivityOffset(remote.nextGroup, now); + const progress = getRemoteBarProgress({ + activeGroups: remote.activeGroups, + now, + }); + const progressBarClassName = classNames('block h-full rounded-full', { + 'bg-blue-500 dark:bg-blue-400': progress.tone === 'normal', + 'bg-yellow-400 dark:bg-yellow-300': progress.tone === 'warning', + 'bg-red-500 dark:bg-red-400': progress.tone === 'overdue', + }); + const activeActivities = remote.activeGroups.flatMap((group) => group.scheduledActivities); + const controlsDisabled = + remote.isSaving || !canUseNotifyCompRemoteControls(notifyCompWebSocketStatus.status); + + const confirmNextGroup = (group: RemoteActivityGroup) => + confirm({ + confirmLabel: 'Advance', + message: ( +
+

+ Advance to {group.name}? This will update Live Activities for this + competition. +

+ {activeActivities.length > 0 && ( +
+

Activities that will stop

+ +
+ )} +
+

Activities that will start

+ +
+
+ ), + }); + + const confirmFinishCompetition = () => + confirm({ + confirmLabel: 'Finish competition', + message: ( +
+

+ Finish this competition? This will set an end time for the final active Live Activities. +

+
+

Activities that will stop

+ +
+
+ ), + }); + + const runSwitch = async (direction: 'previous' | 'next') => { + const group = direction === 'previous' ? remote.previousGroup : remote.nextGroup; + + if (direction === 'previous') { + await remote.switchToPreviousGroup(); + return; + } + + if (direction === 'next' && !group && hasActiveGroups) { + if (await confirmFinishCompetition()) { + await remote.finishAllActivities(); + } + return; + } + + if (direction === 'next' && group && !(await confirmNextGroup(group))) { + return; + } + + await remote.switchToGroup(group); + }; + + const togglePlayback = async () => { + if (remote.activeGroups.length > 0) { + await Promise.all(remote.activeGroups.map((group) => remote.stopGroup(group))); + return; + } + + if (remote.nextGroup && !(await confirmNextGroup(remote.nextGroup))) { + return; + } + + await remote.switchToGroup(remote.nextGroup); + }; + + return ( + + ); +} diff --git a/src/components/NotifyCompRemoteBar/index.ts b/src/components/NotifyCompRemoteBar/index.ts new file mode 100644 index 0000000..dd69b8a --- /dev/null +++ b/src/components/NotifyCompRemoteBar/index.ts @@ -0,0 +1 @@ +export * from './NotifyCompRemoteBar'; diff --git a/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts b/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts new file mode 100644 index 0000000..dca0a94 --- /dev/null +++ b/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts @@ -0,0 +1,176 @@ +import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities'; +import { + formatElapsedDuration, + formatElapsedMMSS, + formatNextActivityOffset, + getRemoteBarProgress, +} from './remoteBarProgress'; + +const group = (overrides: Partial): RemoteActivityGroup => ({ + id: '333-r1-g1', + liveActivities: [], + name: '3x3x3 Cube, Round 1, Group 1', + scheduledActivities: [ + { + activityCode: '333-r1-g1', + childActivities: [], + endTime: '2026-06-01T10:10:00Z', + extensions: [], + id: 101, + name: '3x3x3 Cube, Round 1, Group 1', + room: { + id: 1, + name: 'Stage 1', + } as RemoteActivityGroup['scheduledActivities'][number]['room'], + startTime: '2026-06-01T10:00:00Z', + }, + ], + status: 'next', + ...overrides, +}); + +describe('remoteBarProgress', () => { + it('formats elapsed live activity time as MM:SS', () => { + expect(formatElapsedMMSS('2026-06-01T10:01:05Z', new Date('2026-06-01T10:06:09Z'))).toBe( + '05:04', + ); + }); + + it('formats elapsed time against scheduled activity duration', () => { + expect( + formatElapsedDuration( + [ + group({ + liveActivities: [ + { + activityId: 101, + endTime: null, + startTime: '2026-06-01T10:01:05Z', + }, + ], + status: 'current', + }), + ], + new Date('2026-06-01T10:06:09Z'), + ), + ).toBe('05:04 / 10 minutes'); + }); + + it('describes how many minutes remain until the next activity', () => { + expect( + formatNextActivityOffset( + group({ + name: 'Clock, Round 1, Group 1', + scheduledActivities: [ + { + ...group({}).scheduledActivities[0], + startTime: '2026-06-01T10:14:15Z', + }, + ], + }), + new Date('2026-06-01T10:10:00Z'), + ), + ).toBe('In 5 minutes'); + }); + + it('describes how many minutes late the next activity is', () => { + expect( + formatNextActivityOffset( + group({ + scheduledActivities: [ + { + ...group({}).scheduledActivities[0], + startTime: '2026-06-01T10:05:00Z', + }, + ], + }), + new Date('2026-06-01T10:10:00Z'), + ), + ).toBe('5 minutes ago'); + }); + + it('describes far future activity starts in hours', () => { + expect( + formatNextActivityOffset( + group({ + scheduledActivities: [ + { + ...group({}).scheduledActivities[0], + startTime: '2026-06-01T13:45:00Z', + }, + ], + }), + new Date('2026-06-01T10:10:00Z'), + ), + ).toBe('In 4 hours'); + }); + + it('fills progress based on live start time and scheduled activity duration', () => { + expect( + getRemoteBarProgress({ + activeGroups: [ + group({ + liveActivities: [ + { + activityId: 101, + endTime: null, + startTime: '2026-06-01T10:00:00Z', + }, + ], + status: 'current', + }), + ], + now: new Date('2026-06-01T10:05:00Z'), + }), + ).toEqual({ + percent: 50, + tone: 'normal', + }); + }); + + it('uses a warning tone once the activity is within 5 percent of done', () => { + expect( + getRemoteBarProgress({ + activeGroups: [ + group({ + liveActivities: [ + { + activityId: 101, + endTime: null, + startTime: '2026-06-01T10:00:00Z', + }, + ], + status: 'current', + }), + ], + now: new Date('2026-06-01T10:09:30Z'), + }), + ).toEqual({ + percent: 95, + tone: 'warning', + }); + }); + + it('uses an overdue tone once the scheduled duration has elapsed', () => { + expect( + getRemoteBarProgress({ + activeGroups: [ + group({ + liveActivities: [ + { + activityId: 101, + endTime: null, + startTime: '2026-06-01T10:00:00Z', + }, + ], + status: 'current', + }), + ], + now: new Date('2026-06-01T10:11:00Z'), + }), + ).toEqual({ + percent: 100, + tone: 'overdue', + }); + }); +}); diff --git a/src/components/NotifyCompRemoteBar/remoteBarProgress.ts b/src/components/NotifyCompRemoteBar/remoteBarProgress.ts new file mode 100644 index 0000000..6761489 --- /dev/null +++ b/src/components/NotifyCompRemoteBar/remoteBarProgress.ts @@ -0,0 +1,154 @@ +import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities'; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +const clampPercent = (value: number) => Math.min(100, Math.max(0, value)); + +const getTime = (isoString?: string | null) => { + if (!isoString) { + return undefined; + } + + const time = new Date(isoString).getTime(); + return Number.isNaN(time) ? undefined : time; +}; + +const getEarliestTime = (times: Array) => { + const validTimes = times.filter((time): time is number => time !== undefined); + return validTimes.length ? Math.min(...validTimes) : undefined; +}; + +const getLatestTime = (times: Array) => { + const validTimes = times.filter((time): time is number => time !== undefined); + return validTimes.length ? Math.max(...validTimes) : undefined; +}; + +export const formatElapsedMMSS = (startTime: number | string | undefined, now: Date) => { + const start = typeof startTime === 'number' ? startTime : getTime(startTime); + + if (start === undefined) { + return '00:00'; + } + + const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - start) / SECOND)); + const minutes = Math.floor(elapsedSeconds / 60); + const seconds = elapsedSeconds % 60; + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; +}; + +export const getGroupScheduledStartTime = (group?: RemoteActivityGroup) => + getEarliestTime(group?.scheduledActivities.map((activity) => getTime(activity.startTime)) || []); + +const getActiveGroupsScheduledStartTime = (groups: RemoteActivityGroup[]) => + getEarliestTime( + groups.flatMap((group) => + group.scheduledActivities.map((activity) => getTime(activity.startTime)), + ), + ); + +const getActiveGroupsScheduledEndTime = (groups: RemoteActivityGroup[]) => + getLatestTime( + groups.flatMap((group) => + group.scheduledActivities.map((activity) => getTime(activity.endTime)), + ), + ); + +export const getActiveGroupStartTime = (groups: RemoteActivityGroup[]) => + getEarliestTime( + groups.flatMap((group) => { + const liveStartTimes = group.liveActivities + .filter((activity) => activity.startTime && !activity.endTime) + .map((activity) => getTime(activity.startTime)); + + if (liveStartTimes.length > 0) { + return liveStartTimes; + } + + return group.scheduledActivities.map((activity) => getTime(activity.startTime)); + }), + ); + +export const getActiveGroupsScheduledDuration = (groups: RemoteActivityGroup[]) => { + const start = getActiveGroupsScheduledStartTime(groups); + const end = getActiveGroupsScheduledEndTime(groups); + + if (start === undefined || end === undefined || end <= start) { + return undefined; + } + + return end - start; +}; + +const formatDurationMinutes = (duration: number | undefined) => { + if (duration === undefined) { + return '0 minutes'; + } + + const minutes = Math.max(1, Math.round(duration / MINUTE)); + const unit = minutes === 1 ? 'minute' : 'minutes'; + + return `${minutes} ${unit}`; +}; + +export const formatElapsedDuration = (groups: RemoteActivityGroup[], now: Date) => + `${formatElapsedMMSS(getActiveGroupStartTime(groups), now)} / ${formatDurationMinutes( + getActiveGroupsScheduledDuration(groups), + )}`; + +export const formatNextActivityOffset = (nextGroup: RemoteActivityGroup | undefined, now: Date) => { + const nextStart = getGroupScheduledStartTime(nextGroup); + + if (!nextGroup || nextStart === undefined) { + return 'No next activity'; + } + + const offsetMs = nextStart - now.getTime(); + const absoluteOffsetMinutes = Math.max(1, Math.ceil(Math.abs(offsetMs) / MINUTE)); + const minuteUnit = absoluteOffsetMinutes === 1 ? 'minute' : 'minutes'; + + if (offsetMs < 0) { + return `${absoluteOffsetMinutes} ${minuteUnit} ago`; + } + + if (absoluteOffsetMinutes < 60) { + return `In ${absoluteOffsetMinutes} ${minuteUnit}`; + } + + const remainingHours = Math.ceil(absoluteOffsetMinutes / 60); + const unit = remainingHours === 1 ? 'hour' : 'hours'; + + return `In ${remainingHours} ${unit}`; +}; + +export const getRemoteBarProgress = ({ + activeGroups, + now, +}: { + activeGroups: RemoteActivityGroup[]; + now: Date; +}) => { + const activeStart = getActiveGroupStartTime(activeGroups); + const duration = getActiveGroupsScheduledDuration(activeGroups); + + if (activeStart === undefined || duration === undefined) { + return { + percent: 0, + tone: 'normal' as const, + }; + } + + const elapsed = Math.max(0, now.getTime() - activeStart); + const percent = (elapsed / duration) * 100; + + return { + percent: clampPercent(percent), + tone: + percent > 100 + ? ('overdue' as const) + : percent >= 95 + ? ('warning' as const) + : ('normal' as const), + }; +}; diff --git a/src/components/RemoteActivitySummaryList/RemoteActivitySummaryList.stories.tsx b/src/components/RemoteActivitySummaryList/RemoteActivitySummaryList.stories.tsx new file mode 100644 index 0000000..fc428f4 --- /dev/null +++ b/src/components/RemoteActivitySummaryList/RemoteActivitySummaryList.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { RemoteScheduledActivity } from '@/lib/notifyCompRemoteActivities'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { RemoteActivitySummaryList } from './RemoteActivitySummaryList'; + +const venue = storybookCompetitionFixture.schedule.venues[0]; +const mainRoom = venue.rooms[0]; +const sideRoom = venue.rooms[1]; + +const scheduledActivity = ( + room: typeof mainRoom, + parentIndex: number, + childIndex: number, +): RemoteScheduledActivity => { + const parent = room.activities[parentIndex]; + const child = parent.childActivities[childIndex] || parent; + + return { + ...child, + parent, + room: { + ...room, + venue: { + timezone: venue.timezone, + }, + }, + } as RemoteScheduledActivity; +}; + +const meta = { + title: 'Components/Competition/Remote Activity Summary List', + component: RemoteActivitySummaryList, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const SingleStage: Story = { + args: { + activities: [scheduledActivity(mainRoom, 0, 0)], + }, +}; + +export const MultipleStages: Story = { + args: { + activities: [scheduledActivity(mainRoom, 0, 0), scheduledActivity(sideRoom, 0, 0)], + }, +}; diff --git a/src/components/RemoteActivitySummaryList/RemoteActivitySummaryList.tsx b/src/components/RemoteActivitySummaryList/RemoteActivitySummaryList.tsx new file mode 100644 index 0000000..58e1e63 --- /dev/null +++ b/src/components/RemoteActivitySummaryList/RemoteActivitySummaryList.tsx @@ -0,0 +1,49 @@ +import { RoomPill } from '@/components/Pill'; +import { getRoomData } from '@/lib/activities'; +import { RemoteScheduledActivity } from '@/lib/notifyCompRemoteActivities'; +import { formatTimeRange } from '@/lib/time'; + +interface RemoteActivitySummaryListProps { + activities: RemoteScheduledActivity[]; + roundingMinutes?: number; +} + +const activityTimeZone = (activity: RemoteScheduledActivity) => + (activity.room as RemoteScheduledActivity['room'] & { venue?: { timezone?: string } }).venue + ?.timezone; + +export function RemoteActivitySummaryList({ + activities, + roundingMinutes = 5, +}: RemoteActivitySummaryListProps) { + return ( +
    + {activities.map((activity) => { + const stage = getRoomData(activity.room, activity); + + return ( +
  • + {activity.name} + + + {stage.name} + + + {formatTimeRange( + activity.startTime, + activity.endTime, + roundingMinutes, + activityTimeZone(activity), + )} + + +
  • + ); + })} +
+ ); +} diff --git a/src/components/RemoteActivitySummaryList/index.ts b/src/components/RemoteActivitySummaryList/index.ts new file mode 100644 index 0000000..309ef17 --- /dev/null +++ b/src/components/RemoteActivitySummaryList/index.ts @@ -0,0 +1 @@ +export * from './RemoteActivitySummaryList'; diff --git a/src/components/index.ts b/src/components/index.ts index 209c315..38a0b25 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,4 +16,7 @@ export * from './LastFetchedAt'; export * from './LinkButton'; export * from './LoggedOutPromptCard'; export * from './Notebox'; +export * from './NotifyCompConnectionStatus'; +export * from './NotifyCompRemoteBar'; export * from './PinCompetitionButton'; +export * from './RemoteActivitySummaryList'; diff --git a/src/containers/CompetitionLive/CompetitionLive.tsx b/src/containers/CompetitionLive/CompetitionLive.tsx index 1fb8ce2..0a68b48 100644 --- a/src/containers/CompetitionLive/CompetitionLive.tsx +++ b/src/containers/CompetitionLive/CompetitionLive.tsx @@ -1,4 +1,4 @@ -import { Container } from '@/components/Container'; +import { Container, NotifyCompConnectionStatus } from '@/components'; import { LiveActivities } from '@/containers/LiveActivities'; export interface CompetitionLiveContainerProps { @@ -12,6 +12,7 @@ export function CompetitionLiveContainer({ competitionId }: CompetitionLiveConta Live Activities + ); diff --git a/src/containers/OngoingActivities/OngoingActivities.tsx b/src/containers/OngoingActivities/OngoingActivities.tsx index 2cf5220..7c6816c 100644 --- a/src/containers/OngoingActivities/OngoingActivities.tsx +++ b/src/containers/OngoingActivities/OngoingActivities.tsx @@ -19,20 +19,13 @@ export const OngoingActivities = ({ competitionId }: OngoingActivitiesProps) => ); if (!ongoingActivities?.length) { - const subject = `[${wcif?.shortName}]%20NotifyComp%20Support%20Request`; - const body = `Hello, I am a ${ - roles?.some((i) => i.includes('delegate')) ? 'delegate' : 'organizer' - } for ${wcif?.shortName} and I would like to learn more about live activity support.`; - return isInChargeOfComp && wcif?.id ? (
- - Elevate your competition, learn about live activity support - + to="/live-activities"> + Learn how to use Live Activities for this competition +
) : null; } diff --git a/src/containers/Schedule/Schedule.tsx b/src/containers/Schedule/Schedule.tsx index 7234abc..40006ba 100644 --- a/src/containers/Schedule/Schedule.tsx +++ b/src/containers/Schedule/Schedule.tsx @@ -1,15 +1,11 @@ import { Competition } from '@wca/helpers'; import { useCallback, useEffect, useMemo } from 'react'; -import { ActivityRow } from '@/components'; import { useCollapse } from '@/hooks/UseCollapse'; -import { - getRoomData, - getScheduledDays, - getVenueForActivity, - hasMultipleScheduleLocations, -} from '@/lib/activities'; +import { getScheduledDays, hasMultipleScheduleLocations } from '@/lib/activities'; import { LinkRenderer } from '@/lib/linkRenderer'; import { ActivityWithRoomOrParent } from '@/lib/types'; +import { ScheduleActivityRow } from './ScheduleActivityRow'; +import { getScheduleActivityGroups } from './scheduleActivityGroups'; const key = (compId: string) => `${compId}-schedule`; @@ -27,10 +23,10 @@ const ScheduleDay = ({ LinkComponent?: LinkRenderer; }) => { const { collapsedDates, toggleDate } = useCollapse(key(wcif.id)); - - const findVenue = useCallback(() => { - return getVenueForActivity(wcif); - }, [wcif]); + const activityGroups = useMemo( + () => getScheduleActivityGroups(wcif, activities), + [activities, wcif], + ); const collapsed = collapsedDates.includes(date); const toggleCollapsed = useCallback(() => { @@ -46,24 +42,14 @@ const ScheduleDay = ({ {collapsed ? ' ▼' : ' ▲'}
- {(collapsed ? [] : activities).map((activity) => { - const venue = findVenue()(activity); - const timeZone = venue?.timezone ?? wcif.schedule.venues?.[0]?.timezone ?? ''; - const room = activity?.parent?.parent?.room || activity?.parent?.room || activity?.room; - const stage = room ? getRoomData(room, activity) : undefined; - - return ( - - ); - })} + {(collapsed ? [] : activityGroups).map((group) => ( + + ))}
); diff --git a/src/containers/Schedule/ScheduleActivityRow.tsx b/src/containers/Schedule/ScheduleActivityRow.tsx new file mode 100644 index 0000000..524229f --- /dev/null +++ b/src/containers/Schedule/ScheduleActivityRow.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; +import { RoomPill } from '@/components/Pill'; +import { useNow } from '@/hooks/useNow'; +import { LinkRenderer } from '@/lib/linkRenderer'; +import { formatTimeRange } from '@/lib/time'; +import { ScheduleActivityGroup } from './scheduleActivityGroups'; + +interface ScheduleActivityRowProps { + group: ScheduleActivityGroup; + LinkComponent?: LinkRenderer; + showRoom?: boolean; +} + +export function ScheduleActivityRow({ + group, + LinkComponent = Link, + showRoom = true, +}: ScheduleActivityRowProps) { + const now = useNow(); + const isOver = new Date(group.endTime).getTime() < now.getTime(); + + return ( + + {group.name} + + {showRoom && ( + + {group.rooms.map((room) => ( + + {room.name} + + ))} + + )} + + {formatTimeRange(group.startTime, group.endTime, 5, group.timeZone)} + + + + ); +} diff --git a/src/containers/Schedule/scheduleActivityGroups.test.ts b/src/containers/Schedule/scheduleActivityGroups.test.ts new file mode 100644 index 0000000..b9ffd66 --- /dev/null +++ b/src/containers/Schedule/scheduleActivityGroups.test.ts @@ -0,0 +1,111 @@ +import { getScheduleActivityGroups, getScheduleActivityPath } from './scheduleActivityGroups'; + +jest.mock('@/i18n', () => ({ + __esModule: true, + default: { + t: (key: string, options?: { groupNumber?: number; roundNumber?: number }) => + key === 'common.activityCodeToName.group' + ? `Group ${options?.groupNumber}` + : key === 'common.activityCodeToName.round' + ? `Round ${options?.roundNumber}` + : key, + }, + t: (key: string, options?: { groupNumber?: number; roundNumber?: number }) => + key === 'common.activityCodeToName.group' + ? `Group ${options?.groupNumber}` + : key === 'common.activityCodeToName.round' + ? `Round ${options?.roundNumber}` + : key, +})); + +const makeActivity = ({ + activityCode, + id, + name, + room, +}: { + activityCode: string; + id: number; + name: string; + room: { color: string; id: number; name: string }; +}) => ({ + activityCode, + childActivities: [], + endTime: '2026-06-01T10:10:00Z', + extensions: [], + id, + name, + parent: { + activityCode: '333-r1', + childActivities: [], + endTime: '2026-06-01T11:00:00Z', + extensions: [], + id: id + 1000, + name: '3x3x3 Cube, Round 1', + room, + startTime: '2026-06-01T10:00:00Z', + }, + startTime: '2026-06-01T10:00:00Z', +}); + +describe('scheduleActivityGroups', () => { + it('groups matching activity codes and keeps unique room chips', () => { + const firstRoom = { color: '#ff0000', id: 1, name: 'Red room' }; + const secondRoom = { color: '#0000ff', id: 2, name: 'Blue room' }; + const firstActivity = makeActivity({ + activityCode: '333-r1-g1', + id: 101, + name: '3x3x3 Cube, Round 1, Group 1', + room: firstRoom, + }); + const secondActivity = makeActivity({ + activityCode: '333-r1-g1', + id: 102, + name: '3x3x3 Cube, Round 1, Group 1', + room: secondRoom, + }); + const wcif = { + id: 'ExampleComp2026', + schedule: { + venues: [ + { + rooms: [ + { + ...firstRoom, + activities: [{ ...firstActivity.parent, childActivities: [firstActivity] }], + }, + { + ...secondRoom, + activities: [{ ...secondActivity.parent, childActivities: [secondActivity] }], + }, + ], + timezone: 'America/Los_Angeles', + }, + ], + }, + }; + + const groups = getScheduleActivityGroups( + wcif as never, + [firstActivity, secondActivity] as never, + ); + + expect(groups).toHaveLength(1); + expect(groups[0].activityCode).toBe('333-r1-g1'); + expect(groups[0].rooms.map((room) => room.name)).toEqual(['Red room', 'Blue room']); + expect(groups[0].to).toBe('/competitions/ExampleComp2026/events/333-r1/1'); + }); + + it('links grouped activities to the group page', () => { + const activity = makeActivity({ + activityCode: '333-r2-g3', + id: 201, + name: '3x3x3 Cube, Round 2, Group 3', + room: { color: '#ff0000', id: 1, name: 'Red room' }, + }); + + expect(getScheduleActivityPath('ExampleComp2026', activity as never)).toBe( + '/competitions/ExampleComp2026/events/333-r2/3', + ); + }); +}); diff --git a/src/containers/Schedule/scheduleActivityGroups.ts b/src/containers/Schedule/scheduleActivityGroups.ts new file mode 100644 index 0000000..73023b6 --- /dev/null +++ b/src/containers/Schedule/scheduleActivityGroups.ts @@ -0,0 +1,117 @@ +import { Competition, Room, Venue } from '@wca/helpers'; +import { getRoomData, getVenueForActivity } from '@/lib/activities'; +import { activityCodeToName, parseActivityCodeFlexible, toRoundId } from '@/lib/activityCodes'; +import { ActivityWithRoomOrParent } from '@/lib/types'; + +export interface ScheduleActivityLocation { + color: string; + id: string; + name: string; +} + +export interface ScheduleActivityGroup { + activities: ActivityWithRoomOrParent[]; + activityCode: string; + endTime: string; + id: string; + name: string; + rooms: ScheduleActivityLocation[]; + startTime: string; + timeZone?: Venue['timezone']; + to: string; +} + +const getActivityRoom = (activity: ActivityWithRoomOrParent): Room | undefined => + activity?.parent?.parent?.room || activity?.parent?.room || activity?.room; + +const getActivityLocation = ( + activity: ActivityWithRoomOrParent, +): ScheduleActivityLocation | undefined => { + const room = getActivityRoom(activity); + if (!room) { + return undefined; + } + + const roomData = getRoomData(room, activity); + return { + color: roomData.color, + id: `${room.id}-${roomData.name}`, + name: roomData.name, + }; +}; + +const uniqueLocations = (activities: ActivityWithRoomOrParent[]) => + Array.from( + new Map( + activities + .map(getActivityLocation) + .filter((location): location is ScheduleActivityLocation => Boolean(location)) + .map((location) => [location.id, location]), + ).values(), + ); + +const getActivityName = (activity: ActivityWithRoomOrParent) => + activity.activityCode.startsWith('other') + ? activity.name || activity.activityCode + : activityCodeToName(activity.activityCode); + +export const getScheduleActivityPath = ( + competitionId: string, + activity: ActivityWithRoomOrParent, +) => { + try { + const { groupNumber, roundNumber } = parseActivityCodeFlexible(activity.activityCode); + + if (roundNumber && groupNumber) { + return `/competitions/${competitionId}/events/${toRoundId(activity.activityCode)}/${groupNumber}`; + } + + if (roundNumber) { + return `/competitions/${competitionId}/events/${toRoundId(activity.activityCode)}`; + } + } catch { + return `/competitions/${competitionId}/activities/${activity.id}`; + } + + return `/competitions/${competitionId}/activities/${activity.id}`; +}; + +export const getScheduleActivityGroups = ( + wcif: Competition, + activities: ActivityWithRoomOrParent[], +): ScheduleActivityGroup[] => { + const findVenue = getVenueForActivity(wcif); + const groupsByActivityCode = activities.reduce((groups, activity) => { + const group = groups.get(activity.activityCode) || []; + group.push(activity); + groups.set(activity.activityCode, group); + return groups; + }, new Map()); + + return Array.from(groupsByActivityCode.entries()) + .map(([activityCode, groupedActivities]) => { + const sortedActivities = groupedActivities.toSorted((a, b) => + a.startTime.localeCompare(b.startTime), + ); + const firstActivity = sortedActivities[0]; + const startTime = sortedActivities.map((activity) => activity.startTime).toSorted()[0]; + const endTime = + sortedActivities + .map((activity) => activity.endTime) + .toSorted() + .at(-1) || ''; + + return { + activities: sortedActivities, + activityCode, + endTime, + id: activityCode, + name: firstActivity ? getActivityName(firstActivity) : activityCode, + rooms: uniqueLocations(sortedActivities), + startTime, + timeZone: firstActivity ? findVenue(firstActivity)?.timezone : undefined, + to: firstActivity ? getScheduleActivityPath(wcif.id, firstActivity) : '', + }; + }) + .sort((a, b) => a.startTime.localeCompare(b.startTime)); +}; diff --git a/src/hooks/useCompetitionRemoteControl/index.ts b/src/hooks/useCompetitionRemoteControl/index.ts new file mode 100644 index 0000000..bd761ca --- /dev/null +++ b/src/hooks/useCompetitionRemoteControl/index.ts @@ -0,0 +1 @@ +export * from './useCompetitionRemoteControl'; diff --git a/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts new file mode 100644 index 0000000..1694987 --- /dev/null +++ b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts @@ -0,0 +1,296 @@ +import { renderHook, act } from '@testing-library/react'; +import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities'; +import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import { useCompetitionRemoteControl } from './useCompetitionRemoteControl'; + +jest.mock('@/hooks/useNotifyCompRemoteActivities', () => ({ + useNotifyCompRemoteActivities: jest.fn(), +})); +jest.mock('@/providers/AuthProvider', () => ({ + useAuth: jest.fn(), +})); +jest.mock('@/providers/NotifyCompRemoteAuthProvider', () => ({ + useNotifyCompRemoteAuth: jest.fn(), +})); +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: jest.fn(), +})); + +const activity = ({ + activityCode, + id, + name, + startTime, +}: { + activityCode: string; + id: number; + name: string; + startTime: string; +}) => ({ + activityCode, + childActivities: [], + endTime: '2026-06-01T10:10:00Z', + extensions: [], + id, + name, + startTime, +}); + +const wcif = { + persons: [ + { + roles: ['delegate'], + wcaUserId: 1, + }, + { + roles: ['staff-dataentry'], + wcaUserId: 2, + }, + ], + schedule: { + venues: [ + { + rooms: [ + { + activities: [ + activity({ + activityCode: '333-r1-g1', + id: 101, + name: '3x3x3 Cube, Round 1, Group 1', + startTime: '2026-06-01T10:00:00Z', + }), + activity({ + activityCode: '222-r1-g1', + id: 102, + name: '2x2x2 Cube, Round 1, Group 1', + startTime: '2026-06-01T10:20:00Z', + }), + ], + id: 1, + name: 'Stage 1', + }, + ], + }, + ], + }, +}; + +const wcifWithFourGroups = { + ...wcif, + schedule: { + venues: [ + { + rooms: [ + { + activities: [ + activity({ + activityCode: '333-r1-g1', + id: 101, + name: '3x3x3 Cube, Round 1, Group 1', + startTime: '2026-06-01T10:00:00Z', + }), + activity({ + activityCode: '333-r1-g2', + id: 102, + name: '3x3x3 Cube, Round 1, Group 2', + startTime: '2026-06-01T10:20:00Z', + }), + activity({ + activityCode: '333-r1-g3', + id: 103, + name: '3x3x3 Cube, Round 1, Group 3', + startTime: '2026-06-01T10:40:00Z', + }), + activity({ + activityCode: '333-r1-g4', + id: 104, + name: '3x3x3 Cube, Round 1, Group 4', + startTime: '2026-06-01T11:00:00Z', + }), + ], + id: 1, + name: 'Stage 1', + }, + ], + }, + ], + }, +}; + +describe('useCompetitionRemoteControl', () => { + it('stops and resets the current group before starting the previous group', async () => { + const resetActivities = jest.fn().mockResolvedValue(undefined); + const startActivities = jest.fn().mockResolvedValue(undefined); + const stopActivities = jest.fn().mockResolvedValue(undefined); + + jest.mocked(useWCIF).mockReturnValue({ wcif } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + isAuthenticatedForCompetition: () => true, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteActivities).mockReturnValue({ + activities: [ + { + activityId: 101, + endTime: '2026-06-01T10:09:00Z', + startTime: '2026-06-01T10:01:00Z', + }, + { + activityId: 102, + endTime: null, + startTime: '2026-06-01T10:21:00Z', + }, + ], + resetActivities, + startActivities, + stopActivities, + } as unknown as ReturnType); + + const { result } = renderHook(() => + useCompetitionRemoteControl({ competitionId: 'ExampleComp2026' }), + ); + + await act(async () => { + await result.current.switchToPreviousGroup(); + }); + + expect(stopActivities).toHaveBeenCalledWith([102]); + expect(resetActivities).toHaveBeenCalledWith([102]); + expect(startActivities).toHaveBeenCalledWith([101]); + expect(stopActivities.mock.invocationCallOrder[0]).toBeLessThan( + resetActivities.mock.invocationCallOrder[0], + ); + expect(resetActivities.mock.invocationCallOrder[0]).toBeLessThan( + startActivities.mock.invocationCallOrder[0], + ); + }); + + it('does not load or perform remote actions for users who are not listed delegates or organizers', async () => { + const startActivities = jest.fn().mockResolvedValue(undefined); + + jest.mocked(useWCIF).mockReturnValue({ wcif } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 2 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + isAuthenticatedForCompetition: () => true, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteActivities).mockReturnValue({ + activities: [], + importCompetition: jest.fn(), + resetAllActivities: jest.fn(), + startActivities, + updateAutoAdvance: jest.fn(), + } as unknown as ReturnType); + + const { result } = renderHook(() => + useCompetitionRemoteControl({ competitionId: 'ExampleComp2026' }), + ); + + expect(useNotifyCompRemoteActivities).toHaveBeenCalledWith({ + competitionId: 'ExampleComp2026', + enabled: false, + roomId: undefined, + }); + expect(result.current.canManageRemote).toBe(false); + + await expect(result.current.startGroup(result.current.activityGroups[0])).rejects.toThrow( + 'Only listed competition delegates and organizers can manage remote control.', + ); + expect(startActivities).not.toHaveBeenCalled(); + }); + + it('finishes every started activity that has no end time', async () => { + const stopActivities = jest.fn().mockResolvedValue(undefined); + + jest.mocked(useWCIF).mockReturnValue({ wcif } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + isAuthenticatedForCompetition: () => true, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteActivities).mockReturnValue({ + activities: [ + { + activityId: 101, + endTime: null, + startTime: '2026-06-01T10:01:00Z', + }, + { + activityId: 102, + endTime: '2026-06-01T10:25:00Z', + startTime: '2026-06-01T10:21:00Z', + }, + { + activityId: 103, + endTime: null, + startTime: null, + }, + ], + stopActivities, + } as unknown as ReturnType); + + const { result } = renderHook(() => + useCompetitionRemoteControl({ competitionId: 'ExampleComp2026' }), + ); + + await act(async () => { + await result.current.finishAllActivities(); + }); + + expect(stopActivities).toHaveBeenCalledWith([101]); + }); + + it('starts and stops skipped next groups before starting a later target group', async () => { + const startActivities = jest.fn().mockResolvedValue(undefined); + const stopActivities = jest.fn().mockResolvedValue(undefined); + + jest.mocked(useWCIF).mockReturnValue({ + wcif: wcifWithFourGroups, + } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + isAuthenticatedForCompetition: () => true, + } as unknown as ReturnType); + jest.mocked(useNotifyCompRemoteActivities).mockReturnValue({ + activities: [ + { + activityId: 101, + endTime: null, + startTime: '2026-06-01T10:01:00Z', + }, + ], + startActivities, + stopActivities, + } as unknown as ReturnType); + + const { result } = renderHook(() => + useCompetitionRemoteControl({ competitionId: 'ExampleComp2026' }), + ); + + await act(async () => { + await result.current.startGroup(result.current.activityGroups[3]); + }); + + expect(stopActivities).toHaveBeenNthCalledWith(1, [101]); + expect(startActivities).toHaveBeenNthCalledWith(1, [102, 103]); + expect(stopActivities).toHaveBeenNthCalledWith(2, [102, 103]); + expect(startActivities).toHaveBeenNthCalledWith(2, [104]); + expect(stopActivities.mock.invocationCallOrder[0]).toBeLessThan( + startActivities.mock.invocationCallOrder[0], + ); + expect(startActivities.mock.invocationCallOrder[0]).toBeLessThan( + stopActivities.mock.invocationCallOrder[1], + ); + expect(stopActivities.mock.invocationCallOrder[1]).toBeLessThan( + startActivities.mock.invocationCallOrder[1], + ); + }); +}); diff --git a/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts new file mode 100644 index 0000000..6d9529e --- /dev/null +++ b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts @@ -0,0 +1,226 @@ +import { useMemo } from 'react'; +import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; +import { + getRemoteActiveGroups, + getRemoteActivityGroups, + getRemoteActivityStates, + getRemoteNextGroup, + getRemotePreviousGroup, + getRemoteScheduledActivities, + RemoteActivityGroup, +} from '@/lib/notifyCompRemoteActivities'; +import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; + +interface UseCompetitionRemoteControlParams { + competitionId: string; + enabled?: boolean; + roomId?: number; +} + +const activityIdsForGroup = (group: RemoteActivityGroup) => + group.scheduledActivities.map((activity) => activity.id); + +const isActiveGroup = (group: RemoteActivityGroup) => + group.status === 'current' || group.status === 'mixed'; + +export function useCompetitionRemoteControl({ + competitionId, + enabled = true, + roomId, +}: UseCompetitionRemoteControlParams) { + const { wcif } = useWCIF(); + const { user } = useAuth(); + const remoteAuth = useNotifyCompRemoteAuth(); + const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, user); + const isAuthenticated = remoteAuth.isAuthenticatedForCompetition(competitionId); + const isEnabled = enabled && isAuthenticated && canManageRemote; + + const remote = useNotifyCompRemoteActivities({ + competitionId, + enabled: isEnabled, + roomId, + }); + + const scheduledActivities = useMemo( + () => (wcif ? getRemoteScheduledActivities(wcif, roomId) : []), + [roomId, wcif], + ); + + const activityStates = useMemo( + () => getRemoteActivityStates(scheduledActivities, remote.activities), + [remote.activities, scheduledActivities], + ); + + const activityGroups = useMemo( + () => getRemoteActivityGroups(scheduledActivities, remote.activities), + [remote.activities, scheduledActivities], + ); + + const activeGroups = useMemo(() => getRemoteActiveGroups(activityGroups), [activityGroups]); + const previousGroup = useMemo(() => getRemotePreviousGroup(activityGroups), [activityGroups]); + const nextGroup = useMemo(() => getRemoteNextGroup(activityGroups), [activityGroups]); + + const requireRemoteAccess = () => { + if (!canManageRemote) { + throw new Error( + 'Only listed competition delegates and organizers can manage remote control.', + ); + } + }; + + const finishSkippedGroupsBefore = async (group: RemoteActivityGroup) => { + const targetIndex = activityGroups.findIndex((candidate) => candidate.id === group.id); + + if (targetIndex < 0) { + return; + } + + const skippedActivityIds = activityGroups + .slice(0, targetIndex) + .filter((candidate) => candidate.status === 'next') + .flatMap(activityIdsForGroup); + + if (skippedActivityIds.length === 0) { + return; + } + + await remote.startActivities(skippedActivityIds); + await remote.stopActivities(skippedActivityIds); + }; + + const startGroup = async (group: RemoteActivityGroup) => { + requireRemoteAccess(); + const currentActivityIds = activeGroups.flatMap(activityIdsForGroup); + + if (currentActivityIds.length > 0) { + await remote.stopActivities(currentActivityIds); + } + + await finishSkippedGroupsBefore(group); + return remote.startActivities(activityIdsForGroup(group)); + }; + const stopGroup = (group: RemoteActivityGroup) => { + requireRemoteAccess(); + return remote.stopActivities(activityIdsForGroup(group)); + }; + const resetGroup = (group: RemoteActivityGroup) => { + requireRemoteAccess(); + return remote.resetActivities(activityIdsForGroup(group)); + }; + const startActivity = (activityId: number) => { + requireRemoteAccess(); + return remote.startActivity(activityId); + }; + const stopActivity = (activityId: number) => { + requireRemoteAccess(); + return remote.stopActivity(activityId); + }; + const resetActivity = (activityId: number) => { + requireRemoteAccess(); + return remote.resetActivity(activityId); + }; + const startActivities = (activityIds: number[]) => { + requireRemoteAccess(); + return remote.startActivities(activityIds); + }; + const stopActivities = (activityIds: number[]) => { + requireRemoteAccess(); + return remote.stopActivities(activityIds); + }; + const resetActivities = (activityIds: number[]) => { + requireRemoteAccess(); + return remote.resetActivities(activityIds); + }; + const importCompetition = () => { + requireRemoteAccess(); + return remote.importCompetition(); + }; + const resetAllActivities = () => { + requireRemoteAccess(); + return remote.resetAllActivities(); + }; + const finishAllActivities = () => { + requireRemoteAccess(); + const unfinishedActivityIds = remote.activities + .filter((activity) => activity.startTime && !activity.endTime) + .map((activity) => activity.activityId); + + if (unfinishedActivityIds.length === 0) { + return Promise.resolve(); + } + + return remote.stopActivities(unfinishedActivityIds); + }; + const updateAutoAdvance = (autoAdvance: boolean) => { + requireRemoteAccess(); + return remote.updateAutoAdvance(autoAdvance); + }; + + const switchToGroup = async (group?: RemoteActivityGroup) => { + requireRemoteAccess(); + + if (!group) { + return; + } + + const currentActivityIds = activeGroups.flatMap(activityIdsForGroup); + + if (currentActivityIds.length > 0) { + await remote.stopActivities(currentActivityIds); + } + + if (!isActiveGroup(group)) { + await finishSkippedGroupsBefore(group); + } + + await remote.startActivities(activityIdsForGroup(group)); + }; + + const switchToPreviousGroup = async () => { + requireRemoteAccess(); + + if (!previousGroup) { + return; + } + + const currentActivityIds = activeGroups.flatMap(activityIdsForGroup); + + if (currentActivityIds.length > 0) { + await remote.stopActivities(currentActivityIds); + await remote.resetActivities(currentActivityIds); + } + + await remote.startActivities(activityIdsForGroup(previousGroup)); + }; + + return { + ...remote, + activeGroups, + activityGroups, + activityStates, + canManageRemote, + finishAllActivities, + importCompetition, + isAuthenticated, + isEnabled, + nextGroup, + previousGroup, + resetActivities, + resetActivity, + resetAllActivities, + resetGroup, + scheduledActivities, + startActivities, + startActivity, + startGroup, + stopActivities, + stopActivity, + stopGroup, + switchToPreviousGroup, + switchToGroup, + updateAutoAdvance, + }; +} diff --git a/src/hooks/useNotifyCompRemoteActivities/index.ts b/src/hooks/useNotifyCompRemoteActivities/index.ts new file mode 100644 index 0000000..de57cc4 --- /dev/null +++ b/src/hooks/useNotifyCompRemoteActivities/index.ts @@ -0,0 +1 @@ +export * from './useNotifyCompRemoteActivities'; diff --git a/src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts b/src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts new file mode 100644 index 0000000..1d780b9 --- /dev/null +++ b/src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts @@ -0,0 +1,206 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { useEffect, useMemo, useState } from 'react'; +import { + ImportRemoteCompetitionDocument, + NotifyCompActivity, + NotifyCompCompetition, + RemoteActivitiesDocument, + RemoteActivitiesSubscriptionDocument, + RemoteCompetitionDocument, + ResetRemoteActivitiesDocument, + ResetRemoteActivityDocument, + StartRemoteActivitiesDocument, + StartRemoteActivityDocument, + StopRemoteActivitiesDocument, + StopRemoteActivityDocument, + UpdateRemoteAutoAdvanceDocument, +} from '@/lib/notifyCompRemoteGraphql'; + +interface UseNotifyCompRemoteActivitiesParams { + competitionId: string; + enabled?: boolean; + roomId?: number; +} + +export function useNotifyCompRemoteActivities({ + competitionId, + enabled = true, + roomId, +}: UseNotifyCompRemoteActivitiesParams) { + const [mutationError, setMutationError] = useState(null); + + const activitiesQuery = useQuery<{ activities: NotifyCompActivity[] }>(RemoteActivitiesDocument, { + variables: { competitionId, roomId }, + skip: !competitionId || !enabled, + }); + + const competitionQuery = useQuery<{ competition: NotifyCompCompetition | null }>( + RemoteCompetitionDocument, + { + variables: { competitionId }, + skip: !competitionId || !enabled, + }, + ); + const { subscribeToMore } = activitiesQuery; + + useEffect(() => { + if (!competitionId || !enabled || !subscribeToMore) { + return; + } + + const unsubscribe = subscribeToMore<{ activity: NotifyCompActivity }>({ + document: RemoteActivitiesSubscriptionDocument, + variables: { competitionIds: [competitionId] }, + updateQuery: (prev, { subscriptionData }) => { + if (!subscriptionData.data?.activity) { + return prev; + } + + const activity = subscriptionData.data.activity; + + return { + ...prev, + activities: [ + ...(prev.activities || []).filter( + (candidate) => candidate.activityId !== activity.activityId, + ), + activity, + ], + }; + }, + }); + + return () => unsubscribe(); + }, [competitionId, enabled, subscribeToMore]); + + const refetchQueries = useMemo( + () => [ + { + query: RemoteActivitiesDocument, + variables: { competitionId, roomId }, + }, + { + query: RemoteCompetitionDocument, + variables: { competitionId }, + }, + ], + [competitionId, roomId], + ); + + const mutationOptions = { + refetchQueries, + onError: (error: Error) => { + setMutationError(error.message); + }, + }; + + const [startActivity, startActivityStatus] = useMutation( + StartRemoteActivityDocument, + mutationOptions, + ); + const [stopActivity, stopActivityStatus] = useMutation( + StopRemoteActivityDocument, + mutationOptions, + ); + const [resetActivity, resetActivityStatus] = useMutation( + ResetRemoteActivityDocument, + mutationOptions, + ); + const [startActivities, startActivitiesStatus] = useMutation( + StartRemoteActivitiesDocument, + mutationOptions, + ); + const [stopActivities, stopActivitiesStatus] = useMutation( + StopRemoteActivitiesDocument, + mutationOptions, + ); + const [resetActivities, resetActivitiesStatus] = useMutation( + ResetRemoteActivitiesDocument, + mutationOptions, + ); + const [updateAutoAdvance, updateAutoAdvanceStatus] = useMutation( + UpdateRemoteAutoAdvanceDocument, + mutationOptions, + ); + const [importCompetition, importCompetitionStatus] = useMutation( + ImportRemoteCompetitionDocument, + mutationOptions, + ); + + const runMutation = async (operation: () => Promise) => { + setMutationError(null); + await operation(); + }; + + return { + activities: activitiesQuery.data?.activities || [], + autoAdvance: competitionQuery.data?.competition?.autoAdvance, + competition: competitionQuery.data?.competition || null, + error: + mutationError || activitiesQuery.error?.message || competitionQuery.error?.message || null, + isLoading: activitiesQuery.loading || competitionQuery.loading, + isSaving: + importCompetitionStatus.loading || + startActivityStatus.loading || + stopActivityStatus.loading || + resetActivityStatus.loading || + startActivitiesStatus.loading || + stopActivitiesStatus.loading || + resetActivitiesStatus.loading || + updateAutoAdvanceStatus.loading, + importCompetition: () => + runMutation(() => + importCompetition({ + variables: { competitionId }, + }), + ), + resetActivities: (activityIds: number[]) => + runMutation(() => + resetActivities({ + variables: { competitionId, activityIds }, + }), + ), + resetAllActivities: () => + runMutation(() => + resetActivities({ + variables: { competitionId }, + }), + ), + resetActivity: (activityId: number) => + runMutation(() => + resetActivity({ + variables: { competitionId, activityId }, + }), + ), + startActivities: (activityIds: number[]) => + runMutation(() => + startActivities({ + variables: { competitionId, activityIds }, + }), + ), + startActivity: (activityId: number) => + runMutation(() => + startActivity({ + variables: { competitionId, activityId }, + }), + ), + stopActivities: (activityIds: number[]) => + runMutation(() => + stopActivities({ + variables: { competitionId, activityIds }, + }), + ), + stopActivity: (activityId: number) => + runMutation(() => + stopActivity({ + variables: { competitionId, activityId }, + }), + ), + updateAutoAdvance: (autoAdvance: boolean) => + runMutation(() => + updateAutoAdvance({ + variables: { competitionId, autoAdvance }, + }), + ), + }; +} diff --git a/src/hooks/useNotifyCompWebSocketStatus.ts b/src/hooks/useNotifyCompWebSocketStatus.ts new file mode 100644 index 0000000..d434225 --- /dev/null +++ b/src/hooks/useNotifyCompWebSocketStatus.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from 'react'; +import { + getNotifyCompWebSocketStatus, + subscribeToNotifyCompWebSocketStatus, +} from '@/lib/notifyCompWebSocketStatus'; + +export const useNotifyCompWebSocketStatus = () => + useSyncExternalStore( + subscribeToNotifyCompWebSocketStatus, + getNotifyCompWebSocketStatus, + getNotifyCompWebSocketStatus, + ); diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx index 1b28f14..effe2b7 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx @@ -2,6 +2,7 @@ import { Competition } from '@wca/helpers'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { streamActivities } from '@/lib/activities'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; import { isStaff } from '@/lib/person'; import { useAuth } from '@/providers/AuthProvider'; @@ -13,11 +14,13 @@ interface CompetitionLayoutTabsProps { export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLayoutTabsProps) => { const { t } = useTranslation(); const { user } = useAuth(); + const userId = user?.id; return useMemo(() => { const hasStream = wcif && streamActivities(wcif).length > 0; - const person = wcif?.persons.find((p) => p.wcaUserId === user?.id); + const person = wcif?.persons.find((p) => p.wcaUserId === userId); const isPersonStaff = person && isStaff(person); + const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, userId ? { id: userId } : null); const _tabs: { href: string; @@ -57,6 +60,15 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay }); } + if (canManageRemote) { + _tabs.push({ + href: `/competitions/${competitionId}/remote`, + text: t('header.tabs.remote', { + defaultValue: 'Remote', + }), + }); + } + if (hasStream) { _tabs.push({ href: `/competitions/${competitionId}/stream`, @@ -65,5 +77,5 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay } return _tabs; - }, [wcif, competitionId, user?.id, t]); + }, [wcif, competitionId, userId, t]); }; diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tsx index 115040f..cd496bb 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { Outlet, useLocation, useParams } from 'react-router-dom'; import { BarLoader } from 'react-spinners'; -import { ErrorFallback, LastFetchedAt, NoteBox } from '@/components'; +import { ErrorFallback, LastFetchedAt, NoteBox, NotifyCompRemoteBar } from '@/components'; import { Container } from '@/components/Container'; import { StyledNavLink } from '@/components/StyledNavLink/StyledNavLink'; import { useWcif } from '@/hooks/queries/useWcif'; @@ -65,7 +65,7 @@ export function CompetitionLayout() { )} {isFetching ? :
}
@@ -80,6 +80,7 @@ export function CompetitionLayout() { )}
+ {competitionId && }
); diff --git a/src/lib/competitionAuthorization.test.ts b/src/lib/competitionAuthorization.test.ts new file mode 100644 index 0000000..e4837ba --- /dev/null +++ b/src/lib/competitionAuthorization.test.ts @@ -0,0 +1,31 @@ +import { isCompetitionDelegateOrOrganizer } from './competitionAuthorization'; + +const wcif = { + persons: [ + { + roles: ['organizer'], + wcaUserId: 1, + }, + { + roles: ['trainee-delegate'], + wcaUserId: 2, + }, + { + roles: ['staff-dataentry'], + wcaUserId: 3, + }, + ], +}; + +describe('isCompetitionDelegateOrOrganizer', () => { + it('allows listed organizers and delegates', () => { + expect(isCompetitionDelegateOrOrganizer(wcif as never, { id: 1 })).toBe(true); + expect(isCompetitionDelegateOrOrganizer(wcif as never, { id: 2 })).toBe(true); + }); + + it('rejects non-manager staff and missing users', () => { + expect(isCompetitionDelegateOrOrganizer(wcif as never, { id: 3 })).toBe(false); + expect(isCompetitionDelegateOrOrganizer(wcif as never, { id: 4 })).toBe(false); + expect(isCompetitionDelegateOrOrganizer(wcif as never, null)).toBe(false); + }); +}); diff --git a/src/lib/competitionAuthorization.ts b/src/lib/competitionAuthorization.ts new file mode 100644 index 0000000..30e0a36 --- /dev/null +++ b/src/lib/competitionAuthorization.ts @@ -0,0 +1,18 @@ +import { Competition } from '@wca/helpers'; +import { isDelegate, isOrganizer } from './person'; + +interface CompetitionManager { + id?: number; +} + +export const isCompetitionDelegateOrOrganizer = ( + wcif: Competition | null | undefined, + user: CompetitionManager | null | undefined, +) => { + if (!wcif || !user?.id) { + return false; + } + + const person = wcif.persons.find((candidate) => candidate.wcaUserId === user.id); + return Boolean(person && (isDelegate(person) || isOrganizer(person))); +}; diff --git a/src/lib/competitionDates.test.ts b/src/lib/competitionDates.test.ts new file mode 100644 index 0000000..f98859d --- /dev/null +++ b/src/lib/competitionDates.test.ts @@ -0,0 +1,16 @@ +import { isCompetitionDayOrAfter } from './competitionDates'; + +const wcif = { + schedule: { + numberOfDays: 2, + startDate: '2026-06-01', + }, +}; + +describe('competitionDates', () => { + it('detects dates on or after the competition start date', () => { + expect(isCompetitionDayOrAfter(wcif as never, new Date(2026, 4, 31))).toBe(false); + expect(isCompetitionDayOrAfter(wcif as never, new Date(2026, 5, 1))).toBe(true); + expect(isCompetitionDayOrAfter(wcif as never, new Date(2026, 5, 3))).toBe(true); + }); +}); diff --git a/src/lib/competitionDates.ts b/src/lib/competitionDates.ts index f9c7fd5..629dc41 100644 --- a/src/lib/competitionDates.ts +++ b/src/lib/competitionDates.ts @@ -20,3 +20,10 @@ export const isCompetitionDay = (wcif: Competition, date = new Date()) => { return getDateKey(competitionDate); }).includes(currentDateKey); }; + +export const isCompetitionDayOrAfter = (wcif: Competition, date = new Date()) => { + const currentDate = getLocalDateFromKey(getDateKey(date)); + const startDate = getLocalDateFromKey(wcif.schedule.startDate); + + return currentDate >= startDate; +}; diff --git a/src/lib/notifications/assignmentNotifications.ts b/src/lib/notifications/assignmentNotifications.ts index e23a21e..e97cb13 100644 --- a/src/lib/notifications/assignmentNotifications.ts +++ b/src/lib/notifications/assignmentNotifications.ts @@ -1,4 +1,5 @@ import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage'; +import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken'; const NOTIFY_COMP_ORIGIN = import.meta.env.VITE_NOTIFY_COMP_ORIGIN ?? 'https://api.notifycomp.com/api'; @@ -26,17 +27,6 @@ const notifyCompUrl = (path: string) => `${NOTIFY_COMP_ORIGIN}${path}`; export const isAssignmentNotificationsEnabled = () => getLocalStorage(ENABLED_STORAGE_KEY) === 'true'; -const getAccessToken = () => { - const expiresAt = Number(getLocalStorage('expirationTime') ?? 0); - const accessToken = getLocalStorage('accessToken'); - - if (!accessToken || !expiresAt || expiresAt <= Date.now()) { - return null; - } - - return accessToken; -}; - const toUint8Array = (base64: string) => { const padding = '='.repeat((4 - (base64.length % 4)) % 4); const normalized = `${base64}${padding}`.replace(/-/g, '+').replace(/_/g, '/'); @@ -59,7 +49,7 @@ export const getAssignmentNotificationStatus = (): AssignmentNotificationStatus return 'unsupported'; } - if (!getAccessToken()) { + if (!getStoredWcaAccessToken()) { return 'reauthorize'; } @@ -90,7 +80,7 @@ const readErrorMessage = async (response: Response) => { }; const fetchNotifyCompToken = async () => { - const accessToken = getAccessToken(); + const accessToken = getStoredWcaAccessToken(); if (!accessToken) { throw new Error('Refresh your WCA authorization to enable assignment notifications.'); } diff --git a/src/lib/notifyCompRemoteActivities.test.ts b/src/lib/notifyCompRemoteActivities.test.ts new file mode 100644 index 0000000..7fd78ea --- /dev/null +++ b/src/lib/notifyCompRemoteActivities.test.ts @@ -0,0 +1,148 @@ +import { + getRemoteActivityGroups, + getRemoteActivityState, + getRemoteNextGroup, + getRemotePreviousGroup, + RemoteScheduledActivity, +} from './notifyCompRemoteActivities'; + +const room = { + id: 1, + name: 'Stage 1', +} as RemoteScheduledActivity['room']; + +const activity = (overrides: Partial): RemoteScheduledActivity => + ({ + activityCode: '333-r1-g1', + childActivities: [], + endTime: '2026-06-01T10:10:00Z', + extensions: [], + id: 101, + name: '3x3x3 Cube Round 1 Group 1', + room, + startTime: '2026-06-01T10:00:00Z', + ...overrides, + }) as RemoteScheduledActivity; + +describe('notifyCompRemoteActivities', () => { + it('marks an activity as next when NotifyComp has no live record', () => { + expect(getRemoteActivityState(activity({}), []).status).toBe('next'); + }); + + it('marks an activity as current when it has a start without an end', () => { + expect( + getRemoteActivityState(activity({ id: 102 }), [ + { + activityId: 102, + startTime: '2026-06-01T10:01:00Z', + endTime: null, + }, + ]).status, + ).toBe('current'); + }); + + it('marks an activity as done when it has an end time', () => { + expect( + getRemoteActivityState(activity({ id: 103 }), [ + { + activityId: 103, + startTime: '2026-06-01T10:01:00Z', + endTime: '2026-06-01T10:09:00Z', + }, + ]).status, + ).toBe('done'); + }); + + it('groups matching start times and activity codes across rooms', () => { + const groups = getRemoteActivityGroups( + [ + activity({ id: 201, activityCode: '333-r1-g1', room }), + activity({ + id: 202, + activityCode: '333-r1-g1', + room: { + ...room, + id: 2, + name: 'Stage 2', + }, + }), + ], + [], + ); + + expect(groups).toHaveLength(1); + expect(groups[0].scheduledActivities.map((candidate) => candidate.id)).toEqual([201, 202]); + }); + + it('keeps matching activity codes separate when their start times differ', () => { + const groups = getRemoteActivityGroups( + [ + activity({ id: 211, activityCode: '333-r1-g1' }), + activity({ + id: 212, + activityCode: '333-r1-g1', + startTime: '2026-06-01T10:20:00Z', + }), + ], + [], + ); + + expect(groups).toHaveLength(2); + }); + + it('keeps different group activity codes separate at the same start time', () => { + const groups = getRemoteActivityGroups( + [ + activity({ id: 221, activityCode: '333-r1-g1' }), + activity({ id: 222, activityCode: '333-r1-g2' }), + ], + [], + ); + + expect(groups).toHaveLength(2); + }); + + it('finds the next group after the current activity group', () => { + const groups = getRemoteActivityGroups( + [ + activity({ id: 301, activityCode: '333-r1-g1' }), + activity({ + id: 302, + activityCode: '222-r1-g1', + name: '2x2x2 Cube Round 1 Group 1', + startTime: '2026-06-01T10:20:00Z', + }), + ], + [{ activityId: 301, startTime: '2026-06-01T10:01:00Z', endTime: null }], + ); + + expect(getRemoteNextGroup(groups)?.scheduledActivities[0].id).toBe(302); + }); + + it('finds the previous group before the current activity group', () => { + const groups = getRemoteActivityGroups( + [ + activity({ + id: 401, + activityCode: '222-r1-g1', + name: '2x2x2 Cube Round 1 Group 1', + }), + activity({ + id: 402, + activityCode: '333-r1-g1', + startTime: '2026-06-01T10:20:00Z', + }), + ], + [ + { + activityId: 401, + startTime: '2026-06-01T10:01:00Z', + endTime: '2026-06-01T10:09:00Z', + }, + { activityId: 402, startTime: '2026-06-01T10:21:00Z', endTime: null }, + ], + ); + + expect(getRemotePreviousGroup(groups)?.scheduledActivities[0].id).toBe(401); + }); +}); diff --git a/src/lib/notifyCompRemoteActivities.ts b/src/lib/notifyCompRemoteActivities.ts new file mode 100644 index 0000000..81059ae --- /dev/null +++ b/src/lib/notifyCompRemoteActivities.ts @@ -0,0 +1,177 @@ +import { Activity, Competition, Room } from '@wca/helpers'; +import { getRooms } from './activities'; +import { NotifyCompActivity } from './notifyCompRemoteGraphql'; + +export interface RemoteScheduledActivity extends Activity { + parent?: Activity; + room: Room; +} + +export interface RemoteActivityState { + liveActivity?: NotifyCompActivity; + scheduledActivity: RemoteScheduledActivity; + status: 'next' | 'current' | 'done'; +} + +export interface RemoteActivityGroup { + id: string; + name: string; + scheduledActivities: RemoteScheduledActivity[]; + liveActivities: NotifyCompActivity[]; + status: 'next' | 'current' | 'done' | 'mixed'; +} + +export const getRemoteScheduledActivities = (wcif: Competition, roomId?: number) => + getRooms(wcif) + .filter((room) => roomId === undefined || room.id === roomId) + .flatMap((room) => + room.activities.flatMap((activity) => { + const childActivities = activity.childActivities.map((child) => ({ + ...child, + parent: activity, + })); + const activities = childActivities.length ? childActivities : [activity]; + + return activities.map((scheduledActivity) => ({ + ...scheduledActivity, + room, + })); + }), + ) + .sort((a, b) => a.startTime.localeCompare(b.startTime)); + +export const getRemoteActivityState = ( + scheduledActivity: RemoteScheduledActivity, + liveActivities: NotifyCompActivity[], +): RemoteActivityState => { + const liveActivity = liveActivities.find( + (activity) => activity.activityId === scheduledActivity.id, + ); + + if (liveActivity?.startTime && !liveActivity.endTime) { + return { + liveActivity, + scheduledActivity, + status: 'current', + }; + } + + if (liveActivity?.endTime) { + return { + liveActivity, + scheduledActivity, + status: 'done', + }; + } + + return { + liveActivity, + scheduledActivity, + status: 'next', + }; +}; + +export const getRemoteActivityStates = ( + scheduledActivities: RemoteScheduledActivity[], + liveActivities: NotifyCompActivity[], +) => scheduledActivities.map((activity) => getRemoteActivityState(activity, liveActivities)); + +const getGroupActivityCode = (activity: Activity) => { + const code = activity.activityCode || activity.name; + return code; +}; + +const getGroupKey = (activity: Activity) => + `${activity.startTime}-${getGroupActivityCode(activity)}`; + +export const getRemoteActivityGroups = ( + scheduledActivities: RemoteScheduledActivity[], + liveActivities: NotifyCompActivity[], +): RemoteActivityGroup[] => { + const groups = scheduledActivities.reduce>( + (acc, activity) => { + const key = getGroupKey(activity); + acc[key] = [...(acc[key] || []), activity]; + return acc; + }, + {}, + ); + + return Object.entries(groups).map(([id, activities]) => { + const activityIds = new Set(activities.map((activity) => activity.id)); + const groupLiveActivities = liveActivities.filter((activity) => + activityIds.has(activity.activityId), + ); + const states = getRemoteActivityStates(activities, liveActivities); + const statuses = new Set(states.map((state) => state.status)); + + return { + id, + name: activities[0]?.name || activities[0]?.parent?.name || id, + scheduledActivities: activities, + liveActivities: groupLiveActivities, + status: statuses.size === 1 ? states[0].status : 'mixed', + }; + }); +}; + +export const splitRemoteActivityGroups = (groups: RemoteActivityGroup[]) => ({ + current: groups.filter((group) => group.status === 'current' || group.status === 'mixed'), + next: groups.filter((group) => group.status === 'next'), + done: groups.filter((group) => group.status === 'done'), +}); + +export const getRemoteActiveGroups = (groups: RemoteActivityGroup[]) => + groups.filter((group) => group.status === 'current' || group.status === 'mixed'); + +export const getRemoteNavigationFocusIndex = (groups: RemoteActivityGroup[]) => { + const activeIndex = groups.findIndex( + (group) => group.status === 'current' || group.status === 'mixed', + ); + + if (activeIndex >= 0) { + return activeIndex; + } + + const nextIndex = groups.findIndex((group) => group.status === 'next'); + + return nextIndex >= 0 ? nextIndex : groups.length - 1; +}; + +export const getRemotePreviousGroup = (groups: RemoteActivityGroup[]) => { + const focusIndex = getRemoteNavigationFocusIndex(groups); + + if (focusIndex <= 0) { + return undefined; + } + + return [...groups.slice(0, focusIndex)] + .reverse() + .find((group) => group.status !== 'current' && group.status !== 'mixed'); +}; + +export const getRemoteNextGroup = (groups: RemoteActivityGroup[]) => { + const activeIndex = groups.findIndex( + (group) => group.status === 'current' || group.status === 'mixed', + ); + + if (activeIndex < 0) { + return groups.find((group) => group.status === 'next'); + } + + const focusIndex = getRemoteNavigationFocusIndex(groups); + + if (focusIndex < 0) { + return groups.find((group) => group.status === 'next'); + } + + return groups + .slice(focusIndex + 1) + .find((group) => group.status !== 'done' && group.status !== 'current'); +}; + +export const splitRemoteActivityStates = (states: RemoteActivityState[]) => ({ + current: states.filter((state) => state.status === 'current'), + next: states.filter((state) => state.status === 'next'), + done: states.filter((state) => state.status === 'done'), +}); diff --git a/src/lib/notifyCompRemoteAuth.test.ts b/src/lib/notifyCompRemoteAuth.test.ts new file mode 100644 index 0000000..5bc153f --- /dev/null +++ b/src/lib/notifyCompRemoteAuth.test.ts @@ -0,0 +1,51 @@ +import { + clearNotifyCompRemoteToken, + getNotifyCompRemoteClaims, + hasNotifyCompRemoteToken, + setNotifyCompRemoteToken, +} from './notifyCompRemoteAuth'; + +jest.mock('./localStorage', () => ({ + deleteLocalStorage: (key: string) => localStorage.removeItem(key), + getLocalStorage: (key: string) => localStorage.getItem(key), + setLocalStorage: (key: string, value: string) => localStorage.setItem(key, value), +})); + +const base64Url = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url').replace(/=/g, ''); + +const jwtWithClaims = (claims: Record) => + `${base64Url({ alg: 'HS256', typ: 'JWT' })}.${base64Url(claims)}.signature`; + +describe('notifyCompRemoteAuth', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + clearNotifyCompRemoteToken(); + }); + + it('reads an unpadded remote JWT payload as an authenticated session', () => { + setNotifyCompRemoteToken( + jwtWithClaims({ + competitionIds: ['KentSpring2026'], + exp: Math.floor(Date.now() / 1000) + 60, + name: 'Test Delegate', + }), + ); + + expect(getNotifyCompRemoteClaims()?.name).toBe('Test Delegate'); + expect(hasNotifyCompRemoteToken()).toBe(true); + }); + + it('clears expired remote JWT payloads', () => { + setNotifyCompRemoteToken( + jwtWithClaims({ + exp: Math.floor(Date.now() / 1000) - 60, + }), + ); + + expect(hasNotifyCompRemoteToken()).toBe(false); + }); +}); diff --git a/src/lib/notifyCompRemoteAuth.ts b/src/lib/notifyCompRemoteAuth.ts new file mode 100644 index 0000000..d8d4f1a --- /dev/null +++ b/src/lib/notifyCompRemoteAuth.ts @@ -0,0 +1,59 @@ +import { deleteLocalStorage, getLocalStorage, setLocalStorage } from './localStorage'; + +const REMOTE_JWT_KEY = 'notifyComp.jwt'; + +interface JwtClaims { + competitionIds?: string[]; + competition_ids?: string[]; + exp?: number; + id?: number; + name?: string; + scope?: string | string[]; + scopes?: string[]; + wcaUserId?: number; +} + +const decodeJwtPayload = (token: string): JwtClaims | null => { + try { + const payload = token.split('.')[1]; + if (!payload) { + return null; + } + + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const normalized = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + return JSON.parse(window.atob(normalized)) as JwtClaims; + } catch { + return null; + } +}; + +export const getNotifyCompRemoteToken = () => { + const token = getLocalStorage(REMOTE_JWT_KEY); + if (!token) { + return null; + } + + const claims = decodeJwtPayload(token); + if (claims?.exp && claims.exp * 1000 <= Date.now()) { + deleteLocalStorage(REMOTE_JWT_KEY); + return null; + } + + return token; +}; + +export const getNotifyCompRemoteClaims = () => { + const token = getNotifyCompRemoteToken(); + return token ? decodeJwtPayload(token) : null; +}; + +export const hasNotifyCompRemoteToken = () => Boolean(getNotifyCompRemoteClaims()); + +export const setNotifyCompRemoteToken = (token: string) => { + setLocalStorage(REMOTE_JWT_KEY, token); +}; + +export const clearNotifyCompRemoteToken = () => { + deleteLocalStorage(REMOTE_JWT_KEY); +}; diff --git a/src/lib/notifyCompRemoteGraphql.ts b/src/lib/notifyCompRemoteGraphql.ts new file mode 100644 index 0000000..ddaf9ce --- /dev/null +++ b/src/lib/notifyCompRemoteGraphql.ts @@ -0,0 +1,135 @@ +import { gql } from '@apollo/client'; + +export interface NotifyCompActivity { + activityId: number; + startTime: string | null; + endTime: string | null; + scheduledStartTime?: string | null; + scheduledEndTime?: string | null; +} + +export interface NotifyCompCompetition { + id: string; + autoAdvance?: boolean | null; + autoAdvanceDelay?: number | null; +} + +export const RemoteActivityFragment = gql` + fragment RemoteActivityFragment on Activity { + activityId + startTime + endTime + scheduledStartTime + scheduledEndTime + } +`; + +export const RemoteCompetitionDocument = gql` + query RemoteCompetition($competitionId: String!) { + competition(competitionId: $competitionId) { + id + autoAdvance + autoAdvanceDelay + } + } +`; + +export const RemoteActivitiesDocument = gql` + query RemoteActivities($competitionId: String!, $roomId: Int) { + activities(competitionId: $competitionId, roomId: $roomId) { + ...RemoteActivityFragment + } + } + ${RemoteActivityFragment} +`; + +export const RemoteActivitiesSubscriptionDocument = gql` + subscription RemoteActivities($competitionIds: [String!]!) { + activity: activityUpdated(competitionIds: $competitionIds) { + ...RemoteActivityFragment + } + } + ${RemoteActivityFragment} +`; + +export const ImportRemoteCompetitionDocument = gql` + mutation ImportRemoteCompetition($competitionId: String!) { + importCompetition(competitionId: $competitionId) { + id + autoAdvance + autoAdvanceDelay + } + } +`; + +export const StartRemoteActivityDocument = gql` + mutation StartRemoteActivity($competitionId: String!, $activityId: Int!) { + startActivity(competitionId: $competitionId, activityId: $activityId) { + ...RemoteActivityFragment + } + } + ${RemoteActivityFragment} +`; + +export const StopRemoteActivityDocument = gql` + mutation StopRemoteActivity($competitionId: String!, $activityId: Int!) { + stopActivity(competitionId: $competitionId, activityId: $activityId) { + ...RemoteActivityFragment + } + } + ${RemoteActivityFragment} +`; + +export const ResetRemoteActivityDocument = gql` + mutation ResetRemoteActivity($competitionId: String!, $activityId: Int!) { + resetActivity(competitionId: $competitionId, activityId: $activityId) { + ...RemoteActivityFragment + } + } + ${RemoteActivityFragment} +`; + +export const StartRemoteActivitiesDocument = gql` + mutation StartRemoteActivities($competitionId: String!, $activityIds: [Int!]!) { + startActivities(competitionId: $competitionId, activityIds: $activityIds) { + ...RemoteActivityFragment + } + } + ${RemoteActivityFragment} +`; + +export const StopRemoteActivitiesDocument = gql` + mutation StopRemoteActivities($competitionId: String!, $activityIds: [Int!]!) { + stopActivities(competitionId: $competitionId, activityIds: $activityIds) { + ...RemoteActivityFragment + } + } + ${RemoteActivityFragment} +`; + +export const ResetRemoteActivitiesDocument = gql` + mutation ResetRemoteActivities($competitionId: String!, $activityIds: [Int!]) { + resetActivities(competitionId: $competitionId, activityIds: $activityIds) { + ...RemoteActivityFragment + } + } + ${RemoteActivityFragment} +`; + +export const UpdateRemoteAutoAdvanceDocument = gql` + mutation UpdateRemoteAutoAdvance( + $competitionId: String! + $autoAdvance: Boolean + $autoAdvanceDelay: Int + ) { + updateAutoAdvance( + competitionId: $competitionId + autoAdvance: $autoAdvance + autoAdvanceDelay: $autoAdvanceDelay + ) { + id + autoAdvance + autoAdvanceDelay + } + } +`; diff --git a/src/lib/notifyCompWebSocketStatus.ts b/src/lib/notifyCompWebSocketStatus.ts new file mode 100644 index 0000000..310756c --- /dev/null +++ b/src/lib/notifyCompWebSocketStatus.ts @@ -0,0 +1,31 @@ +export type NotifyCompWebSocketStatus = 'idle' | 'connecting' | 'connected' | 'disconnected'; + +export interface NotifyCompWebSocketStatusState { + message?: string; + status: NotifyCompWebSocketStatus; +} + +type Listener = () => void; + +let state: NotifyCompWebSocketStatusState = { + status: 'idle', +}; +const listeners = new Set(); + +export const getNotifyCompWebSocketStatus = () => state; + +export const subscribeToNotifyCompWebSocketStatus = (listener: Listener) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +}; + +export const setNotifyCompWebSocketStatus = (nextState: NotifyCompWebSocketStatusState) => { + state = nextState; + listeners.forEach((listener) => listener()); +}; + +export const canUseNotifyCompRemoteControls = (status: NotifyCompWebSocketStatus) => + status === 'connected'; diff --git a/src/lib/remoteConfig.ts b/src/lib/remoteConfig.ts new file mode 100644 index 0000000..77afa57 --- /dev/null +++ b/src/lib/remoteConfig.ts @@ -0,0 +1,11 @@ +const rawNotifyCompApiOrigin = + import.meta.env.VITE_NOTIFYCOMP_API_ORIGIN || 'https://api.notifycomp.com/api'; + +export const NOTIFYCOMP_GRAPHQL_ORIGIN = rawNotifyCompApiOrigin.endsWith('/graphql') + ? rawNotifyCompApiOrigin + : `${rawNotifyCompApiOrigin.replace(/\/$/, '')}/graphql`; + +export const NOTIFYCOMP_API_ORIGIN = NOTIFYCOMP_GRAPHQL_ORIGIN.replace(/\/graphql$/, ''); + +export const NOTIFYCOMP_WS_ORIGIN = + import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || NOTIFYCOMP_GRAPHQL_ORIGIN.replace(/^http/, 'ws'); diff --git a/src/lib/wcaAccessToken.ts b/src/lib/wcaAccessToken.ts new file mode 100644 index 0000000..f60588c --- /dev/null +++ b/src/lib/wcaAccessToken.ts @@ -0,0 +1,12 @@ +import { getLocalStorage } from './localStorage'; + +export const getStoredWcaAccessToken = () => { + const expiresAt = Number(getLocalStorage('expirationTime') ?? 0); + const accessToken = getLocalStorage('accessToken'); + + if (!accessToken || !expiresAt || expiresAt <= Date.now()) { + return null; + } + + return accessToken; +}; diff --git a/src/pages/Competition/Remote/RemoteActivityList.stories.tsx b/src/pages/Competition/Remote/RemoteActivityList.stories.tsx new file mode 100644 index 0000000..f06812e --- /dev/null +++ b/src/pages/Competition/Remote/RemoteActivityList.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { RemoteActivityGroup, RemoteScheduledActivity } from '@/lib/notifyCompRemoteActivities'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { RemoteGroupList } from './RemoteActivityList'; + +const venue = storybookCompetitionFixture.schedule.venues[0]; +const mainRoom = venue.rooms[0]; +const sideRoom = venue.rooms[1]; + +const scheduledActivity = ( + room: typeof mainRoom, + parentIndex: number, + childIndex: number, +): RemoteScheduledActivity => { + const parent = room.activities[parentIndex]; + const child = parent.childActivities[childIndex] || parent; + + return { + ...child, + parent, + room: { + ...room, + venue: { + timezone: venue.timezone, + }, + }, + } as RemoteScheduledActivity; +}; + +const group = ( + id: string, + status: RemoteActivityGroup['status'], + activities: RemoteScheduledActivity[], +): RemoteActivityGroup => ({ + id, + name: activities[0]?.name || id, + scheduledActivities: activities, + liveActivities: activities.map((activity) => ({ + activityId: activity.id, + startTime: + status === 'current' || status === 'mixed' || status === 'done' ? activity.startTime : null, + endTime: status === 'done' ? activity.endTime : null, + })), + status, +}); + +const currentGroup = group('333-r1-g1', 'current', [ + scheduledActivity(mainRoom, 0, 0), + scheduledActivity(sideRoom, 0, 0), +]); +const nextGroup = group('333-r1-g2', 'next', [ + scheduledActivity(mainRoom, 0, 1), + scheduledActivity(sideRoom, 0, 1), +]); +const doneGroup = group('222-r1-g1', 'done', [scheduledActivity(mainRoom, 3, 0)]); + +const meta = { + title: 'Pages/Competition/Remote/Activity List', + component: RemoteGroupList, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + disabled: false, + onSelectGroup: () => {}, + }, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const MixedStates: Story = { + args: { + groups: [currentGroup, nextGroup, doneGroup], + }, +}; + +export const NoUpcomingActivities: Story = { + args: { + groups: [doneGroup], + }, +}; + +export const Saving: Story = { + args: { + disabled: true, + groups: [currentGroup, nextGroup, doneGroup], + }, +}; diff --git a/src/pages/Competition/Remote/RemoteActivityList.tsx b/src/pages/Competition/Remote/RemoteActivityList.tsx new file mode 100644 index 0000000..7a0590d --- /dev/null +++ b/src/pages/Competition/Remote/RemoteActivityList.tsx @@ -0,0 +1,263 @@ +import classNames from 'classnames'; +import { formatDuration, intervalToDuration } from 'date-fns'; +import { NoteBox } from '@/components'; +import { RoomPill } from '@/components/Pill'; +import { useNow } from '@/hooks/useNow'; +import { activityCodeToName } from '@/lib/activityCodes'; +import { + RemoteActivityGroup, + RemoteScheduledActivity, + RemoteActivityState, + splitRemoteActivityGroups, + splitRemoteActivityStates, +} from '@/lib/notifyCompRemoteActivities'; +import { formatTime, formatTimeRange } from '@/lib/time'; + +const REMOTE_TIME_ROUNDING_MINUTES = 1; + +const formatStartedDuration = (startTime: string, now: Date) => { + const { hours, minutes } = intervalToDuration({ + start: new Date(startTime), + end: now, + }); + + return formatDuration({ hours, minutes }) || 'now'; +}; + +const formatRemoteElapsedDuration = (startTime?: string | null, endTime?: string | null) => { + if (!startTime || !endTime) { + return null; + } + + const { hours, minutes, seconds } = intervalToDuration({ + start: new Date(startTime), + end: new Date(endTime), + }); + + return formatDuration({ hours, minutes, seconds }) || '0 seconds'; +}; + +const stateDescription = (state: RemoteActivityState, now: Date) => { + const timeZone = groupTimeZone(state.scheduledActivity); + + if (state.liveActivity?.startTime && !state.liveActivity.endTime) { + return `Started ${formatStartedDuration(state.liveActivity.startTime, now)} ago`; + } + + if (state.liveActivity?.endTime) { + const elapsed = formatRemoteElapsedDuration( + state.liveActivity.startTime, + state.liveActivity.endTime, + ); + + return `Ended at ${formatTime( + state.liveActivity.endTime, + REMOTE_TIME_ROUNDING_MINUTES, + timeZone, + )}${elapsed ? ` - Duration ${elapsed}` : ''}`; + } + + return `Should start at ${formatTime( + state.scheduledActivity.startTime, + REMOTE_TIME_ROUNDING_MINUTES, + timeZone, + )}`; +}; + +const groupActivityName = (activity: RemoteScheduledActivity) => + activity.activityCode.startsWith('other') + ? activity.name + : activityCodeToName(activity.activityCode); + +const groupRooms = (group: RemoteActivityGroup) => + Array.from( + new Map( + group.scheduledActivities.map((activity) => [activity.room.id, activity.room]), + ).values(), + ); + +const groupTimeZone = (activity: RemoteScheduledActivity) => + (activity.room as RemoteScheduledActivity['room'] & { venue?: { timezone?: string } }).venue + ?.timezone; + +const groupElapsedDuration = (group: RemoteActivityGroup) => { + const startedActivities = group.liveActivities.filter( + (activity) => activity.startTime && activity.endTime, + ); + + if (startedActivities.length === 0) { + return null; + } + + const startTime = startedActivities + .map((activity) => activity.startTime) + .filter(Boolean) + .sort()[0]; + const endTime = startedActivities + .map((activity) => activity.endTime) + .filter(Boolean) + .sort() + .at(-1); + + return formatRemoteElapsedDuration(startTime, endTime); +}; + +interface RemoteActivityListProps { + disabled?: boolean; + onSelectActivity: (state: RemoteActivityState) => void; + states: RemoteActivityState[]; +} + +export function RemoteActivityList({ + disabled, + onSelectActivity, + states, +}: RemoteActivityListProps) { + const now = useNow(); + const groupedStates = splitRemoteActivityStates(states); + + const sections: Array<{ + emptyText?: string; + items: RemoteActivityState[]; + title: string; + }> = [ + { + items: groupedStates.current, + title: 'Current', + }, + { + emptyText: 'No upcoming activities remain for this room.', + items: groupedStates.next, + title: 'Next', + }, + { + items: groupedStates.done, + title: 'Done', + }, + ]; + + return ( +
+ {sections.map((section) => ( +
+

{section.title}

+ {section.items.length === 0 && section.emptyText && ( + + )} +
+ {section.items.map((state) => ( + + ))} +
+
+ ))} +
+ ); +} + +interface RemoteGroupListProps { + disabled?: boolean; + groups: RemoteActivityGroup[]; + onSelectGroup: (group: RemoteActivityGroup) => void; +} + +export function RemoteGroupList({ disabled, groups, onSelectGroup }: RemoteGroupListProps) { + const groupedStates = splitRemoteActivityGroups(groups); + const sections: Array<{ + emptyText?: string; + items: RemoteActivityGroup[]; + title: string; + }> = [ + { + items: groupedStates.current, + title: 'Current', + }, + { + emptyText: 'No upcoming activities remain.', + items: groupedStates.next, + title: 'Next', + }, + { + items: groupedStates.done, + title: 'Done', + }, + ]; + + return ( +
+ {sections.map((section) => ( +
+

{section.title}

+ {section.items.length === 0 && section.emptyText && ( + + )} +
+ {section.items.map((group) => { + const activityIds = group.scheduledActivities.map((activity) => activity.id); + const firstActivity = group.scheduledActivities[0]; + const rooms = groupRooms(group); + const elapsed = group.status === 'done' ? groupElapsedDuration(group) : null; + + return ( + + ); + })} +
+
+ ))} +
+ ); +} diff --git a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx new file mode 100644 index 0000000..5b04f32 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx @@ -0,0 +1,36 @@ +import classNames from 'classnames'; + +interface RemoteAutoAdvanceToggleProps { + checked: boolean; + disabled?: boolean; + onToggle: () => void; +} + +export function RemoteAutoAdvanceToggle({ + checked, + disabled, + onToggle, +}: RemoteAutoAdvanceToggleProps) { + return ( + + ); +} diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx new file mode 100644 index 0000000..10c27cb --- /dev/null +++ b/src/pages/Competition/Remote/index.tsx @@ -0,0 +1,304 @@ +import classNames from 'classnames'; +import { useEffect } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { BarLoader } from 'react-spinners'; +import { + Button, + Container, + NoteBox, + NotifyCompConnectionStatus, + RemoteActivitySummaryList, +} from '@/components'; +import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; +import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus'; +import { isCompetitionDayOrAfter } from '@/lib/competitionDates'; +import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities'; +import { canUseNotifyCompRemoteControls } from '@/lib/notifyCompWebSocketStatus'; +import { useAuth } from '@/providers/AuthProvider'; +import { useConfirm } from '@/providers/ConfirmProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import { RemoteGroupList } from './RemoteActivityList'; +import { RemoteAutoAdvanceToggle } from './RemoteAutoAdvanceToggle'; + +export default function CompetitionRemote() { + const { competitionId } = useParams<{ competitionId: string }>(); + const { wcif, setTitle } = useWCIF(); + const { user } = useAuth(); + const confirm = useConfirm(); + const remoteAuth = useNotifyCompRemoteAuth(); + const notifyCompWebSocketStatus = useNotifyCompWebSocketStatus(); + + useEffect(() => { + setTitle('Remote'); + }, [setTitle]); + + const remote = useCompetitionRemoteControl({ + competitionId: competitionId || '', + }); + + if (!competitionId || !wcif) { + return null; + } + + const hasStartedRemoteActivities = remote.activities.some((activity) => activity.startTime); + const hasUnfinishedRemoteActivities = remote.activities.some( + (activity) => activity.startTime && !activity.endTime, + ); + const hasDoneRemoteActivityGroups = remote.activityGroups.some( + (group) => group.status === 'done', + ); + const hasCurrentRemoteActivityGroups = remote.activityGroups.some( + (group) => group.status === 'current' || group.status === 'mixed', + ); + const isCompetitionInProgressOrPast = isCompetitionDayOrAfter(wcif); + const canResetAllActivities = + Boolean(remote.competition) && hasDoneRemoteActivityGroups && !hasCurrentRemoteActivityGroups; + const canFinishAllActivities = + Boolean(remote.competition) && + !canResetAllActivities && + isCompetitionInProgressOrPast && + (hasUnfinishedRemoteActivities || hasCurrentRemoteActivityGroups); + const canFinishCompetition = canFinishAllActivities && !remote.nextGroup; + const hasFinishedAllStartedActivities = + canResetAllActivities && isCompetitionInProgressOrPast && hasStartedRemoteActivities; + const hasImportedEmptySchedule = + Boolean(remote.competition) && remote.scheduledActivities.length === 0; + const accessDenied = (Boolean(user) || remote.isAuthenticated) && !remote.canManageRemote; + const remoteControlsDisabled = + remote.isSaving || !canUseNotifyCompRemoteControls(notifyCompWebSocketStatus.status); + + const importCompetition = async () => { + const confirmed = await confirm({ + confirmLabel: 'Import schedule', + confirmVariant: 'green', + message: + 'Import the published schedule into Live Activities Remote? This sends the schedule to NotifyComp, the backend service for Live Activities. It will not start anything.', + }); + + if (confirmed) { + await remote.importCompetition(); + } + }; + + const selectGroup = async (group: RemoteActivityGroup) => { + const action = + group.status === 'done' + ? 'reset' + : group.status === 'current' || group.status === 'mixed' + ? 'stop' + : 'start'; + const actionLabel = { + reset: 'Reset', + start: 'Start', + stop: 'Stop', + }[action]; + const confirmed = await confirm({ + confirmLabel: actionLabel, + confirmVariant: action === 'start' ? 'green' : 'gray', + message: ( +
+

+ This will {action} activity: {group.name} +

+ + {action === 'reset' && ( +

The start and stop times will reset as if the activity never happened.

+ )} +
+ ), + }); + + if (!confirmed) { + return; + } + + if (action === 'start') { + await remote.startGroup(group); + return; + } + + if (action === 'reset') { + await remote.resetGroup(group); + return; + } + + await remote.stopGroup(group); + }; + + return ( + +
+
+

+ Remote controls Live Activities for this competition. NotifyComp handles the backend + updates so staff can start, stop, and advance activities from Competition Groups. +

+ + Learn how Live Activities work + +
+ + {remoteAuth.error && } + + {accessDenied ? ( + + ) : !remote.isAuthenticated ? ( +
+

+ Sign in to Live Activities Remote with your WCA account to connect these controls to + this competition. This sign-in is separate from Competition Groups because the live + updates are backed by NotifyComp. +

+ +
+ ) : ( + <> + {remote.isLoading && } + {remote.error && } + {remote.competition && } + + {!remote.isLoading && !remote.competition ? ( +
+ + +
+ ) : hasImportedEmptySchedule ? ( + + ) : ( +
+
+

All rooms

+
+
+
Auto-advance
+
+ Automatically moves Live Activities to the next scheduled group when the + current group ends. +
+
+
+ { + void (async () => { + const autoAdvance = !remote.autoAdvance; + const confirmed = await confirm({ + confirmLabel: autoAdvance ? 'Enable' : 'Disable', + message: autoAdvance + ? 'Enable auto-advance for this competition? Live Activities Remote will move to the next scheduled group when the current group ends.' + : 'Disable auto-advance for this competition? Staff will need to advance Live Activities manually.', + }); + + if (confirmed) { + await remote.updateAutoAdvance(autoAdvance); + } + })(); + }} + /> +
+
+
+ +
+ { + void selectGroup(group); + }} + /> +
+ + {(canResetAllActivities || canFinishAllActivities) && ( +
+ {canFinishAllActivities ? ( + + ) : ( + + )} +
+ )} + + {hasFinishedAllStartedActivities && ( + + )} +
+ )} + + )} +
+
+ ); +} diff --git a/src/pages/LiveActivities/About.tsx b/src/pages/LiveActivities/About.tsx new file mode 100644 index 0000000..4f47469 --- /dev/null +++ b/src/pages/LiveActivities/About.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react'; +import { Container } from '@/components'; + +const steps = [ + { + title: 'Sign in to Remote', + text: 'Live updates use a separate session to make sure only listed delegates and organizers can control them. Even if you are already signed into the site, you may need to sign in once more with your WCA account.', + }, + { + title: 'Import the competition', + text: 'A listed delegate or organizer opens Remote and imports the published competition schedule.', + }, + { + title: 'Start the active group', + text: 'Remote shows one row per activity code with room chips, so multi-room groups can be started together.', + }, + { + title: 'Keep the room display current', + text: 'Start, stop, reset, and auto-advance controls update what competitors and staff see as currently running.', + }, +]; + +export default function LiveActivitiesAbout() { + useEffect(() => { + document.title = 'Live Activities - Competition Groups'; + }, []); + + return ( + +
+
+
+

Live Activities

+

+ Live Activities let delegates and organizers show what is happening right now at a + competition. The Remote tab controls the live state from the schedule you already + publish, with NotifyComp quietly handling the backend updates that keep displays in + sync. +

+
+
+ +
+

How it works

+
+ {steps.map((step, index) => ( +
+ + {index + 1} + +
+

{step.title}

+

{step.text}

+
+
+ ))} +
+
+ +
+

What it looks like

+
+
+
+ + Live Activities + Powered by NotifyComp +
+ Live view +
+
+
+ Clock, Round 1, Group 1 + + Main stage + Started 4 min ago + +
+
+ 3x3x3 Cube, Round 1, Group 2 + + Side stage + Up next + +
+
+
+

+ Competitors can quickly see what is happening now and what is coming next, without + needing to interpret the full schedule. +

+
+ +
+

Who can use it

+

+ Remote controls are available only to listed delegates and organizers for the + competition. Sign in once with your WCA account, then use the Remote tab for any + competition where you are listed. +

+
+
+
+ ); +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index bed7b32..87fef82 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -2,11 +2,13 @@ import { Button, Container } from '@/components'; import { useMyCompetitionsQuery } from '@/containers/MyCompetitions/MyCompetitions.query'; import { useAssignmentNotifications } from '@/hooks/useAssignmentNotifications'; import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; import { Theme, useUserSettings } from '@/providers/UserSettingsProvider'; export default function Settings() { const { theme, setTheme } = useUserSettings(); const { user, signIn } = useAuth(); + const notifyCompRemoteAuth = useNotifyCompRemoteAuth(); const { competitions, isLoading } = useMyCompetitionsQuery(user?.id); const notifications = useAssignmentNotifications({ competitions, @@ -110,6 +112,24 @@ export default function Settings() { {notifications.error &&

{notifications.error}

} + +
+
+

Live Activities Remote

+

+ Manage the separate session used by Live Activities remote controls. +

+

+ {notifyCompRemoteAuth.isAuthenticated ? 'Signed in' : 'Not signed in'} +

+
+ + {notifyCompRemoteAuth.isAuthenticated && ( + + )} +
); diff --git a/src/providers/ConfirmProvider/ConfirmContext.tsx b/src/providers/ConfirmProvider/ConfirmContext.tsx new file mode 100644 index 0000000..ce05c69 --- /dev/null +++ b/src/providers/ConfirmProvider/ConfirmContext.tsx @@ -0,0 +1,16 @@ +import { createContext, ReactNode, useContext } from 'react'; +import { ButtonVariant } from '@/components/Button'; + +export interface ConfirmOptions { + cancelLabel?: string; + confirmLabel?: string; + confirmVariant?: ButtonVariant; + message: ReactNode; + title?: string; +} + +export type ConfirmFn = (options: ConfirmOptions | string) => Promise; + +export const ConfirmContext = createContext(async () => false); + +export const useConfirm = () => useContext(ConfirmContext); diff --git a/src/providers/ConfirmProvider/ConfirmDialog.tsx b/src/providers/ConfirmProvider/ConfirmDialog.tsx new file mode 100644 index 0000000..d826de4 --- /dev/null +++ b/src/providers/ConfirmProvider/ConfirmDialog.tsx @@ -0,0 +1,55 @@ +import { ReactNode, useEffect } from 'react'; +import { Button } from '@/components/Button'; +import { ButtonVariant } from '@/components/Button'; + +export interface ConfirmDialogOptions { + cancelLabel: string; + confirmLabel: string; + confirmVariant: ButtonVariant; + message: ReactNode; + title: string; +} + +interface ConfirmDialogProps { + onCancel: () => void; + onConfirm: () => void; + options: ConfirmDialogOptions; +} + +export function ConfirmDialog({ onCancel, onConfirm, options }: ConfirmDialogProps) { + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onCancel(); + } + }; + + document.addEventListener('keydown', handleKeydown); + + return () => { + document.removeEventListener('keydown', handleKeydown); + }; + }, [onCancel]); + + return ( +
+
+

{options.title}

+ +
{options.message}
+ +
+ + +
+
+
+ ); +} diff --git a/src/providers/ConfirmProvider/ConfirmProvider.test.tsx b/src/providers/ConfirmProvider/ConfirmProvider.test.tsx new file mode 100644 index 0000000..09688e6 --- /dev/null +++ b/src/providers/ConfirmProvider/ConfirmProvider.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { useConfirm } from './ConfirmContext'; +import { ConfirmProvider } from './ConfirmProvider'; + +function ConfirmConsumer() { + const confirm = useConfirm(); + const [result, setResult] = useState('pending'); + + return ( + <> + + {result} + + ); +} + +describe('ConfirmProvider', () => { + it('resolves true when the user confirms', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open confirm' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + expect(await screen.findByText('confirmed')).toBeInTheDocument(); + }); + + it('resolves false when the user cancels', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open confirm' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(await screen.findByText('cancelled')).toBeInTheDocument(); + }); + + it('resolves false when the user presses Escape', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Open confirm' })); + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(await screen.findByText('cancelled')).toBeInTheDocument(); + }); +}); diff --git a/src/providers/ConfirmProvider/ConfirmProvider.tsx b/src/providers/ConfirmProvider/ConfirmProvider.tsx new file mode 100644 index 0000000..f5067bc --- /dev/null +++ b/src/providers/ConfirmProvider/ConfirmProvider.tsx @@ -0,0 +1,66 @@ +import { PropsWithChildren, useCallback, useRef, useState } from 'react'; +import { ConfirmContext, ConfirmOptions } from './ConfirmContext'; +import { ConfirmDialog, ConfirmDialogOptions } from './ConfirmDialog'; + +interface PendingConfirm extends ConfirmDialogOptions { + resolve: (confirmed: boolean) => void; +} + +const normalizeOptions = (options: ConfirmOptions | string): ConfirmOptions => + typeof options === 'string' ? { message: options } : options; + +export function ConfirmProvider({ children }: PropsWithChildren) { + const [pendingConfirm, setPendingConfirm] = useState(null); + const pendingConfirmRef = useRef(null); + const confirmQueueRef = useRef([]); + + const confirm = useCallback((rawOptions: ConfirmOptions | string) => { + const options = normalizeOptions(rawOptions); + + return new Promise((resolve) => { + const nextConfirm = { + cancelLabel: options.cancelLabel || 'Cancel', + confirmLabel: options.confirmLabel || 'Confirm', + confirmVariant: options.confirmVariant || 'blue', + message: options.message, + resolve, + title: options.title || 'Are you sure?', + }; + + if (pendingConfirmRef.current) { + confirmQueueRef.current.push(nextConfirm); + return; + } + + pendingConfirmRef.current = nextConfirm; + setPendingConfirm(nextConfirm); + }); + }, []); + + const close = (confirmed: boolean) => { + const currentConfirm = pendingConfirmRef.current; + if (!currentConfirm) { + return; + } + + currentConfirm.resolve(confirmed); + + const nextConfirm = confirmQueueRef.current.shift() || null; + pendingConfirmRef.current = nextConfirm; + setPendingConfirm(nextConfirm); + }; + + return ( + + {children} + + {pendingConfirm && ( + close(false)} + onConfirm={() => close(true)} + /> + )} + + ); +} diff --git a/src/providers/ConfirmProvider/index.ts b/src/providers/ConfirmProvider/index.ts new file mode 100644 index 0000000..a743384 --- /dev/null +++ b/src/providers/ConfirmProvider/index.ts @@ -0,0 +1,2 @@ +export * from './ConfirmContext'; +export * from './ConfirmProvider'; diff --git a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx new file mode 100644 index 0000000..0040c23 --- /dev/null +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext } from 'react'; + +export interface NotifyCompRemoteAuthContextValue { + authenticating: boolean; + error: string | null; + /** + * Remote sign-in is app-wide; the competition-specific access check lives in + * useCompetitionRemoteControl. + */ + isAuthenticatedForCompetition: (competitionId: string) => boolean; + isAuthenticated: boolean; + signIn: (competitionId: string) => Promise; + signOut: () => void; + userName?: string; +} + +export const NotifyCompRemoteAuthContext = createContext({ + authenticating: false, + error: null, + isAuthenticatedForCompetition: () => false, + isAuthenticated: false, + signIn: async () => {}, + signOut: () => {}, +}); + +export const useNotifyCompRemoteAuth = () => useContext(NotifyCompRemoteAuthContext); diff --git a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx new file mode 100644 index 0000000..b58f6a4 --- /dev/null +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx @@ -0,0 +1,129 @@ +import { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'; +import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage'; +import { + clearNotifyCompRemoteToken, + getNotifyCompRemoteClaims, + getNotifyCompRemoteToken, + hasNotifyCompRemoteToken, + setNotifyCompRemoteToken, +} from '@/lib/notifyCompRemoteAuth'; +import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken'; +import { useAuth } from '../AuthProvider'; +import { NotifyCompRemoteAuthContext } from './NotifyCompRemoteAuthContext'; + +const NOTIFY_COMP_TOKEN_URL = '/.netlify/functions/notify-comp-token'; +const PENDING_REMOTE_COMPETITION_ID_KEY = 'notifyComp.pendingRemoteCompetitionId'; +const REMOTE_SCOPE = 'notifycomp.remote'; + +const readErrorMessage = async (response: Response) => { + const text = await response.text(); + + try { + const payload = JSON.parse(text) as { message?: string }; + return payload.message || text; + } catch { + return text; + } +}; + +export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { + const [token, setToken] = useState(getNotifyCompRemoteToken); + const [authenticating, setAuthenticating] = useState(false); + const [error, setError] = useState(null); + const { signIn: signInWithWca, user } = useAuth(); + + const signIn = useCallback( + async (competitionId: string) => { + setError(null); + const accessToken = getStoredWcaAccessToken(); + + if (!accessToken) { + setLocalStorage(PENDING_REMOTE_COMPETITION_ID_KEY, competitionId); + signInWithWca(); + return; + } + + setAuthenticating(true); + deleteLocalStorage(PENDING_REMOTE_COMPETITION_ID_KEY); + + try { + const response = await fetch(NOTIFY_COMP_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + accessToken, + competitionId, + scope: REMOTE_SCOPE, + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + const payload = (await response.json()) as { token?: string }; + if (!payload.token) { + throw new Error('Remote token response was missing a token.'); + } + + setNotifyCompRemoteToken(payload.token); + setToken(payload.token); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Unable to authorize Live Activities Remote.', + ); + clearNotifyCompRemoteToken(); + setToken(null); + } finally { + setAuthenticating(false); + } + }, + [signInWithWca], + ); + + useEffect(() => { + const pendingCompetitionId = getLocalStorage(PENDING_REMOTE_COMPETITION_ID_KEY); + if (!pendingCompetitionId || authenticating || !user) { + return; + } + + if (hasNotifyCompRemoteToken()) { + deleteLocalStorage(PENDING_REMOTE_COMPETITION_ID_KEY); + return; + } + + if (!getStoredWcaAccessToken()) { + return; + } + + void signIn(pendingCompetitionId); + }, [authenticating, signIn, user]); + + const signOut = useCallback(() => { + clearNotifyCompRemoteToken(); + setToken(null); + }, []); + + const claims = token ? getNotifyCompRemoteClaims() : null; + + const value = useMemo( + () => ({ + authenticating, + error, + isAuthenticatedForCompetition: hasNotifyCompRemoteToken, + isAuthenticated: Boolean(token), + signIn, + signOut, + userName: claims?.name, + }), + [authenticating, claims?.name, error, signIn, signOut, token], + ); + + return ( + + {children} + + ); +} diff --git a/src/providers/NotifyCompRemoteAuthProvider/index.ts b/src/providers/NotifyCompRemoteAuthProvider/index.ts new file mode 100644 index 0000000..69028a6 --- /dev/null +++ b/src/providers/NotifyCompRemoteAuthProvider/index.ts @@ -0,0 +1,2 @@ +export * from './NotifyCompRemoteAuthContext'; +export * from './NotifyCompRemoteAuthProvider';