From fea3281583b92d4d636a08e3652101ed65dee6d6 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 17:11:40 -0700 Subject: [PATCH 01/31] add notifycomp remote controls --- src/App.tsx | 7 +- src/apolloClient.ts | 20 +- .../useNotifyCompRemoteActivities/index.ts | 1 + .../useNotifyCompRemoteActivities.ts | 187 +++++++++++++++ .../CompetitionLayout.tabs.tsx | 7 + src/lib/notifyCompRemoteActivities.test.ts | 74 ++++++ src/lib/notifyCompRemoteActivities.ts | 119 +++++++++ src/lib/notifyCompRemoteAuth.ts | 68 ++++++ src/lib/notifyCompRemoteGraphql.ts | 125 ++++++++++ src/lib/remoteConfig.ts | 14 ++ .../Competition/Remote/RemoteActivityList.tsx | 187 +++++++++++++++ src/pages/Competition/Remote/index.tsx | 225 ++++++++++++++++++ .../NotifyCompRemoteAuthContext.tsx | 20 ++ .../NotifyCompRemoteAuthProvider.tsx | 119 +++++++++ .../NotifyCompRemoteAuthProvider/index.ts | 2 + src/vite-env.d.ts | 1 + 16 files changed, 1172 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useNotifyCompRemoteActivities/index.ts create mode 100644 src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts create mode 100644 src/lib/notifyCompRemoteActivities.test.ts create mode 100644 src/lib/notifyCompRemoteActivities.ts create mode 100644 src/lib/notifyCompRemoteAuth.ts create mode 100644 src/lib/notifyCompRemoteGraphql.ts create mode 100644 src/lib/remoteConfig.ts create mode 100644 src/pages/Competition/Remote/RemoteActivityList.tsx create mode 100644 src/pages/Competition/Remote/index.tsx create mode 100644 src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx create mode 100644 src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx create mode 100644 src/providers/NotifyCompRemoteAuthProvider/index.ts diff --git a/src/App.tsx b/src/App.tsx index a2df4af..c0a0538 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, @@ -36,6 +37,7 @@ import Test from './pages/Test'; import UserLogin from './pages/UserLogin'; import { AppProvider } from './providers/AppProvider'; import { AuthProvider, useAuth } from './providers/AuthProvider'; +import { NotifyCompRemoteAuthProvider } from './providers/NotifyCompRemoteAuthProvider'; import { QueryProvider } from './providers/QueryProvider/QueryProvider'; import { UserSettingsProvider } from './providers/UserSettingsProvider'; import { useWCIF } from './providers/WCIFProvider'; @@ -108,6 +110,7 @@ const Navigation = () => { } /> } /> + } /> } /> } /> @@ -142,7 +145,9 @@ const App = () => ( - + + + diff --git a/src/apolloClient.ts b/src/apolloClient.ts index a8b84e8..c750c54 100644 --- a/src/apolloClient.ts +++ b/src/apolloClient.ts @@ -1,25 +1,39 @@ 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 { 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, }), ); +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/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..8f4726e --- /dev/null +++ b/src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts @@ -0,0 +1,187 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { useEffect, useMemo, useState } from 'react'; +import { + 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 }>( + 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 runMutation = async (operation: () => Promise) => { + setMutationError(null); + await operation(); + }; + + return { + activities: activitiesQuery.data?.activities || [], + autoAdvance: competitionQuery.data?.competition?.autoAdvance, + error: + mutationError || activitiesQuery.error?.message || competitionQuery.error?.message || null, + isLoading: activitiesQuery.loading || competitionQuery.loading, + isSaving: + startActivityStatus.loading || + stopActivityStatus.loading || + resetActivityStatus.loading || + startActivitiesStatus.loading || + stopActivitiesStatus.loading || + resetActivitiesStatus.loading || + updateAutoAdvanceStatus.loading, + resetActivities: (activityIds: number[]) => + runMutation(() => + resetActivities({ + variables: { competitionId, activityIds }, + }), + ), + 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/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx index 1b28f14..8a09064 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx @@ -55,6 +55,13 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay href: `/competitions/${competitionId}/scramblers`, text: t('header.tabs.scramblers'), }); + + _tabs.push({ + href: `/competitions/${competitionId}/remote`, + text: t('header.tabs.remote', { + defaultValue: 'Remote', + }), + }); } if (hasStream) { diff --git a/src/lib/notifyCompRemoteActivities.test.ts b/src/lib/notifyCompRemoteActivities.test.ts new file mode 100644 index 0000000..58f3c70 --- /dev/null +++ b/src/lib/notifyCompRemoteActivities.test.ts @@ -0,0 +1,74 @@ +import { + getRemoteActivityGroups, + getRemoteActivityState, + 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 activity codes across rooms by round code', () => { + const groups = getRemoteActivityGroups( + [ + activity({ id: 201, activityCode: '333-r1-g1', room }), + activity({ + id: 202, + activityCode: '333-r1-g2', + room: { + ...room, + id: 2, + name: 'Stage 2', + }, + }), + ], + [], + ); + + expect(groups).toHaveLength(1); + expect(groups[0].scheduledActivities.map((candidate) => candidate.id)).toEqual([201, 202]); + }); +}); diff --git a/src/lib/notifyCompRemoteActivities.ts b/src/lib/notifyCompRemoteActivities.ts new file mode 100644 index 0000000..ae8b65b --- /dev/null +++ b/src/lib/notifyCompRemoteActivities.ts @@ -0,0 +1,119 @@ +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 normalizeActivityCode = (activity: Activity) => { + const code = activity.activityCode || activity.name; + return code.replace(/-g\d+$/i, ''); +}; + +export const getRemoteActivityGroups = ( + scheduledActivities: RemoteScheduledActivity[], + liveActivities: NotifyCompActivity[], +): RemoteActivityGroup[] => { + const groups = scheduledActivities.reduce>( + (acc, activity) => { + const key = normalizeActivityCode(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]?.parent?.name || activities[0]?.name || id, + scheduledActivities: activities, + liveActivities: groupLiveActivities, + status: statuses.size === 1 ? states[0].status : 'mixed', + }; + }); +}; + +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.ts b/src/lib/notifyCompRemoteAuth.ts new file mode 100644 index 0000000..f2b072e --- /dev/null +++ b/src/lib/notifyCompRemoteAuth.ts @@ -0,0 +1,68 @@ +import { deleteLocalStorage, getLocalStorage, setLocalStorage } from './localStorage'; + +const REMOTE_JWT_KEY = 'notifyComp.jwt'; +const REMOTE_AUTH_PENDING_KEY = 'notifyComp.authPending'; +const REMOTE_REDIRECT_PATH_KEY = 'notifyComp.redirectPath'; + +interface JwtClaims { + exp?: number; + name?: string; + id?: number; +} + +const decodeJwtPayload = (token: string): JwtClaims | null => { + try { + const payload = token.split('.')[1]; + if (!payload) { + return null; + } + + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + 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 setNotifyCompRemoteToken = (token: string) => { + setLocalStorage(REMOTE_JWT_KEY, token); +}; + +export const clearNotifyCompRemoteToken = () => { + deleteLocalStorage(REMOTE_JWT_KEY); +}; + +export const setNotifyCompRemoteAuthPending = (redirectPath: string) => { + setLocalStorage(REMOTE_AUTH_PENDING_KEY, 'true'); + setLocalStorage(REMOTE_REDIRECT_PATH_KEY, redirectPath); +}; + +export const isNotifyCompRemoteAuthPending = () => + getLocalStorage(REMOTE_AUTH_PENDING_KEY) === 'true'; + +export const consumeNotifyCompRemoteRedirectPath = () => { + const redirectPath = getLocalStorage(REMOTE_REDIRECT_PATH_KEY) || '/'; + deleteLocalStorage(REMOTE_AUTH_PENDING_KEY); + deleteLocalStorage(REMOTE_REDIRECT_PATH_KEY); + return redirectPath; +}; diff --git a/src/lib/notifyCompRemoteGraphql.ts b/src/lib/notifyCompRemoteGraphql.ts new file mode 100644 index 0000000..ef8dba4 --- /dev/null +++ b/src/lib/notifyCompRemoteGraphql.ts @@ -0,0 +1,125 @@ +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 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/remoteConfig.ts b/src/lib/remoteConfig.ts new file mode 100644 index 0000000..ef16e77 --- /dev/null +++ b/src/lib/remoteConfig.ts @@ -0,0 +1,14 @@ +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 || 'wss://api.notifycomp.com/api/graphql'; + +export const NOTIFYCOMP_AUTH_ORIGIN = + import.meta.env.VITE_NOTIFYCOMP_AUTH_ORIGIN || NOTIFYCOMP_API_ORIGIN; diff --git a/src/pages/Competition/Remote/RemoteActivityList.tsx b/src/pages/Competition/Remote/RemoteActivityList.tsx new file mode 100644 index 0000000..8b80cd0 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteActivityList.tsx @@ -0,0 +1,187 @@ +import { formatDuration, intervalToDuration } from 'date-fns'; +import { Button, NoteBox } from '@/components'; +import { useNow } from '@/hooks/useNow'; +import { + RemoteActivityGroup, + RemoteActivityState, + splitRemoteActivityStates, +} from '@/lib/notifyCompRemoteActivities'; +import { formatTime } from '@/lib/time'; + +const formatStartedDuration = (startTime: string, now: Date) => { + const { hours, minutes } = intervalToDuration({ + start: new Date(startTime), + end: now, + }); + + return formatDuration({ hours, minutes }) || 'now'; +}; + +const stateDescription = (state: RemoteActivityState, now: Date) => { + if (state.liveActivity?.startTime && !state.liveActivity.endTime) { + return `Started ${formatStartedDuration(state.liveActivity.startTime, now)} ago`; + } + + if (state.liveActivity?.endTime) { + return `Ended at ${formatTime(state.liveActivity.endTime)}`; + } + + return `Scheduled for ${formatTime(state.scheduledActivity.startTime)}`; +}; + +interface RemoteActivityListProps { + disabled?: boolean; + onResetActivity: (state: RemoteActivityState) => void; + onStartActivity: (state: RemoteActivityState) => void; + onStopActivity: (state: RemoteActivityState) => void; + states: RemoteActivityState[]; +} + +export function RemoteActivityList({ + disabled, + onResetActivity, + onStartActivity, + onStopActivity, + 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) => ( +
+
+
{state.scheduledActivity.name}
+
+ {state.scheduledActivity.room.name} - {stateDescription(state, now)} +
+
+
+ {state.status === 'current' ? ( + + ) : ( + + )} + +
+
+ ))} +
+
+ ))} +
+ ); +} + +interface RemoteGroupListProps { + disabled?: boolean; + groups: RemoteActivityGroup[]; + onResetGroup: (group: RemoteActivityGroup) => void; + onStartGroup: (group: RemoteActivityGroup) => void; + onStopGroup: (group: RemoteActivityGroup) => void; +} + +export function RemoteGroupList({ + disabled, + groups, + onResetGroup, + onStartGroup, + onStopGroup, +}: RemoteGroupListProps) { + return ( +
+ {groups.map((group) => { + const activityIds = group.scheduledActivities.map((activity) => activity.id); + const rooms = [...new Set(group.scheduledActivities.map((activity) => activity.room.name))]; + + return ( +
+
+
{group.name}
+
+ {rooms.join(', ')} - {group.scheduledActivities.length} activit + {group.scheduledActivities.length === 1 ? 'y' : 'ies'} - {group.status} +
+
+
+ {group.status === 'current' ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ ); +} diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx new file mode 100644 index 0000000..222839e --- /dev/null +++ b/src/pages/Competition/Remote/index.tsx @@ -0,0 +1,225 @@ +import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { BarLoader } from 'react-spinners'; +import { Button, Container, NoteBox } from '@/components'; +import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities'; +import { getRooms } from '@/lib/activities'; +import { + getRemoteActivityGroups, + getRemoteActivityStates, + getRemoteScheduledActivities, + RemoteActivityGroup, + RemoteActivityState, +} from '@/lib/notifyCompRemoteActivities'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import { RemoteActivityList, RemoteGroupList } from './RemoteActivityList'; + +const activityIdsForGroup = (group: RemoteActivityGroup) => + group.scheduledActivities.map((activity) => activity.id); + +const confirmAction = (message: string) => window.confirm(message); + +export default function CompetitionRemote() { + const { competitionId } = useParams<{ competitionId: string }>(); + const { wcif, setTitle } = useWCIF(); + const remoteAuth = useNotifyCompRemoteAuth(); + const [selectedRoomId, setSelectedRoomId] = useState('all'); + + useEffect(() => { + setTitle('Remote'); + }, [setTitle]); + + const rooms = useMemo(() => (wcif ? getRooms(wcif) : []), [wcif]); + const roomId = selectedRoomId === 'all' ? undefined : selectedRoomId; + const remote = useNotifyCompRemoteActivities({ + competitionId: competitionId || '', + enabled: remoteAuth.isAuthenticated, + 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], + ); + + if (!competitionId || !wcif) { + return null; + } + + const startActivity = (state: RemoteActivityState) => { + if (!confirmAction(`Start ${state.scheduledActivity.name}?`)) { + return; + } + + void remote.startActivity(state.scheduledActivity.id); + }; + + const stopActivity = (state: RemoteActivityState) => { + if (!confirmAction(`Stop ${state.scheduledActivity.name}?`)) { + return; + } + + void remote.stopActivity(state.scheduledActivity.id); + }; + + const resetActivity = (state: RemoteActivityState) => { + if ( + !confirmAction(`Reset ${state.scheduledActivity.name}? This clears its start and stop times.`) + ) { + return; + } + + void remote.resetActivity(state.scheduledActivity.id); + }; + + const startGroup = (group: RemoteActivityGroup) => { + if (!confirmAction(`Start ${group.name} in all listed rooms?`)) { + return; + } + + void remote.startActivities(activityIdsForGroup(group)); + }; + + const stopGroup = (group: RemoteActivityGroup) => { + if (!confirmAction(`Stop ${group.name} in all listed rooms?`)) { + return; + } + + void remote.stopActivities(activityIdsForGroup(group)); + }; + + const resetGroup = (group: RemoteActivityGroup) => { + if (!confirmAction(`Reset ${group.name} in all listed rooms?`)) { + return; + } + + void remote.resetActivities(activityIdsForGroup(group)); + }; + + return ( + +
+
+

Remote

+

+ Control live activity status for {wcif.shortName || wcif.name}. +

+
+ + {remoteAuth.error && } + + {!remoteAuth.isAuthenticated ? ( +
+
+

Remote sign in

+

+ Sign in with NotifyComp Remote to start, stop, reset, or auto-advance activities. +

+
+ +
+ ) : ( + <> +
+
+

Remote session

+

+ {remoteAuth.userName ? `Signed in as ${remoteAuth.userName}` : 'Signed in'} +

+
+ +
+ + {remote.isLoading && } + {remote.error && } + +
+
+
+

Controls

+

+ {selectedRoomId === 'all' ? 'All rooms' : 'Single room'} selected. +

+
+ +
+ +
+ +
+ +
+ {selectedRoomId === 'all' ? ( + + ) : ( + + )} +
+
+ + )} +
+
+ ); +} diff --git a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx new file mode 100644 index 0000000..98c30c1 --- /dev/null +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +export interface NotifyCompRemoteAuthContextValue { + authenticating: boolean; + error: string | null; + isAuthenticated: boolean; + signIn: () => void; + signOut: () => void; + userName?: string; +} + +export const NotifyCompRemoteAuthContext = createContext({ + authenticating: false, + error: null, + isAuthenticated: false, + signIn: () => {}, + 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..dc191f9 --- /dev/null +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx @@ -0,0 +1,119 @@ +import { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { + clearNotifyCompRemoteToken, + consumeNotifyCompRemoteRedirectPath, + getNotifyCompRemoteClaims, + getNotifyCompRemoteToken, + isNotifyCompRemoteAuthPending, + setNotifyCompRemoteAuthPending, + setNotifyCompRemoteToken, +} from '@/lib/notifyCompRemoteAuth'; +import { NOTIFYCOMP_AUTH_ORIGIN } from '@/lib/remoteConfig'; +import { NotifyCompRemoteAuthContext } from './NotifyCompRemoteAuthContext'; + +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 location = useLocation(); + const navigate = useNavigate(); + + const signIn = useCallback(() => { + const redirectPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; + setNotifyCompRemoteAuthPending(redirectPath); + setError(null); + + const params = new URLSearchParams({ + redirect_uri: window.location.href, + }); + + window.location.href = `${NOTIFYCOMP_AUTH_ORIGIN}/auth/wca?${params.toString()}`; + }, []); + + const signOut = useCallback(() => { + clearNotifyCompRemoteToken(); + setToken(null); + }, []); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const code = params.get('code'); + + if (!code || !isNotifyCompRemoteAuthPending()) { + return; + } + + setAuthenticating(true); + setError(null); + + const callbackParams = new URLSearchParams({ + code, + redirect_uri: window.location.href, + }); + + fetch(`${NOTIFYCOMP_AUTH_ORIGIN}/auth/wca/callback?${callbackParams.toString()}`) + .then(async (response) => { + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + return (await response.json()) as { jwt?: string }; + }) + .then(({ jwt }) => { + if (!jwt) { + throw new Error('NotifyComp did not return a remote session token.'); + } + + setNotifyCompRemoteToken(jwt); + setToken(jwt); + + const nextParams = new URLSearchParams(location.search); + nextParams.delete('code'); + const query = nextParams.toString(); + const fallbackPath = `${location.pathname}${query ? `?${query}` : ''}${location.hash}`; + const redirectPath = consumeNotifyCompRemoteRedirectPath() || fallbackPath; + navigate(redirectPath, { replace: true }); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Unable to sign in to NotifyComp Remote.'); + clearNotifyCompRemoteToken(); + setToken(null); + consumeNotifyCompRemoteRedirectPath(); + }) + .finally(() => { + setAuthenticating(false); + }); + }, [location, navigate]); + + const claims = token ? getNotifyCompRemoteClaims() : null; + + const value = useMemo( + () => ({ + authenticating, + error, + 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'; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 22776a0..633f6ff 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,7 @@ declare const __GIT_TAG__: string; interface ImportMetaEnv { readonly VITE_NOTIFY_COMP_ORIGIN?: string; + readonly VITE_NOTIFYCOMP_AUTH_ORIGIN?: string; readonly VITE_NOTIFYCOMP_API_ORIGIN?: string; readonly VITE_NOTIFYCOMP_WS_ORIGIN?: string; } From 7642e05058e721697b07d6b28ed39febfd581609 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 17:32:27 -0700 Subject: [PATCH 02/31] switch remote auth to competitiongroups token exchange --- netlify/functions/notify-comp-token.js | 29 ++++- .../notifications/assignmentNotifications.ts | 16 +-- src/lib/notifyCompRemoteAuth.ts | 32 +++-- src/lib/remoteConfig.ts | 3 - src/lib/wcaAccessToken.ts | 12 ++ src/pages/Competition/Remote/index.tsx | 21 +++- .../NotifyCompRemoteAuthContext.tsx | 6 +- .../NotifyCompRemoteAuthProvider.tsx | 116 ++++++++---------- src/vite-env.d.ts | 1 - 9 files changed, 127 insertions(+), 109 deletions(-) create mode 100644 src/lib/wcaAccessToken.ts diff --git a/netlify/functions/notify-comp-token.js b/netlify/functions/notify-comp-token.js index d63bf79..8c3b867 100644 --- a/netlify/functions/notify-comp-token.js +++ b/netlify/functions/notify-comp-token.js @@ -8,6 +8,8 @@ const headers = { }; const base64Url = (value) => Buffer.from(value).toString('base64url'); +const REMOTE_SCOPE = 'notifycomp.remote'; +const PUSH_SCOPE = 'assignment_notifications'; const signJwt = (claims, secret) => { const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); @@ -42,7 +44,18 @@ 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; if (!accessToken) { return { statusCode: 400, @@ -75,14 +88,28 @@ exports.handler = async (event) => { }; } + const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE; + if (tokenScope === REMOTE_SCOPE && !competitionId) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ message: 'Missing competition ID for remote token' }), + }; + } + const now = Math.floor(Date.now() / 1000); const token = signJwt( { aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp', + competitionIds: tokenScope === REMOTE_SCOPE ? [competitionId] : undefined, exp: now + 10 * 60, 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/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/notifyCompRemoteAuth.ts b/src/lib/notifyCompRemoteAuth.ts index f2b072e..c46f9fb 100644 --- a/src/lib/notifyCompRemoteAuth.ts +++ b/src/lib/notifyCompRemoteAuth.ts @@ -1,13 +1,15 @@ import { deleteLocalStorage, getLocalStorage, setLocalStorage } from './localStorage'; const REMOTE_JWT_KEY = 'notifyComp.jwt'; -const REMOTE_AUTH_PENDING_KEY = 'notifyComp.authPending'; -const REMOTE_REDIRECT_PATH_KEY = 'notifyComp.redirectPath'; interface JwtClaims { + competitionIds?: string[]; exp?: number; - name?: string; id?: number; + name?: string; + scope?: string | string[]; + scopes?: string[]; + wcaUserId?: number; } const decodeJwtPayload = (token: string): JwtClaims | null => { @@ -44,6 +46,15 @@ export const getNotifyCompRemoteClaims = () => { return token ? decodeJwtPayload(token) : null; }; +export const hasNotifyCompRemoteTokenForCompetition = (competitionId: string) => { + const claims = getNotifyCompRemoteClaims(); + if (!claims) { + return false; + } + + return claims.competitionIds?.includes(competitionId) ?? false; +}; + export const setNotifyCompRemoteToken = (token: string) => { setLocalStorage(REMOTE_JWT_KEY, token); }; @@ -51,18 +62,3 @@ export const setNotifyCompRemoteToken = (token: string) => { export const clearNotifyCompRemoteToken = () => { deleteLocalStorage(REMOTE_JWT_KEY); }; - -export const setNotifyCompRemoteAuthPending = (redirectPath: string) => { - setLocalStorage(REMOTE_AUTH_PENDING_KEY, 'true'); - setLocalStorage(REMOTE_REDIRECT_PATH_KEY, redirectPath); -}; - -export const isNotifyCompRemoteAuthPending = () => - getLocalStorage(REMOTE_AUTH_PENDING_KEY) === 'true'; - -export const consumeNotifyCompRemoteRedirectPath = () => { - const redirectPath = getLocalStorage(REMOTE_REDIRECT_PATH_KEY) || '/'; - deleteLocalStorage(REMOTE_AUTH_PENDING_KEY); - deleteLocalStorage(REMOTE_REDIRECT_PATH_KEY); - return redirectPath; -}; diff --git a/src/lib/remoteConfig.ts b/src/lib/remoteConfig.ts index ef16e77..eb927eb 100644 --- a/src/lib/remoteConfig.ts +++ b/src/lib/remoteConfig.ts @@ -9,6 +9,3 @@ export const NOTIFYCOMP_API_ORIGIN = NOTIFYCOMP_GRAPHQL_ORIGIN.replace(/\/graphq export const NOTIFYCOMP_WS_ORIGIN = import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://api.notifycomp.com/api/graphql'; - -export const NOTIFYCOMP_AUTH_ORIGIN = - import.meta.env.VITE_NOTIFYCOMP_AUTH_ORIGIN || NOTIFYCOMP_API_ORIGIN; 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/index.tsx b/src/pages/Competition/Remote/index.tsx index 222839e..b89464e 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -33,9 +33,12 @@ export default function CompetitionRemote() { const rooms = useMemo(() => (wcif ? getRooms(wcif) : []), [wcif]); const roomId = selectedRoomId === 'all' ? undefined : selectedRoomId; + const isRemoteAuthenticated = competitionId + ? remoteAuth.isAuthenticatedForCompetition(competitionId) + : false; const remote = useNotifyCompRemoteActivities({ competitionId: competitionId || '', - enabled: remoteAuth.isAuthenticated, + enabled: isRemoteAuthenticated, roomId, }); @@ -120,16 +123,22 @@ export default function CompetitionRemote() { {remoteAuth.error && } - {!remoteAuth.isAuthenticated ? ( + {!isRemoteAuthenticated ? (
-

Remote sign in

+

Remote authorization

- Sign in with NotifyComp Remote to start, stop, reset, or auto-advance activities. + Authorize this competition with your WCA account to start, stop, reset, or + auto-advance activities.

-
) : ( diff --git a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx index 98c30c1..dcac8be 100644 --- a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx @@ -3,8 +3,9 @@ import { createContext, useContext } from 'react'; export interface NotifyCompRemoteAuthContextValue { authenticating: boolean; error: string | null; + isAuthenticatedForCompetition: (competitionId: string) => boolean; isAuthenticated: boolean; - signIn: () => void; + signIn: (competitionId: string) => Promise; signOut: () => void; userName?: string; } @@ -12,8 +13,9 @@ export interface NotifyCompRemoteAuthContextValue { export const NotifyCompRemoteAuthContext = createContext({ authenticating: false, error: null, + isAuthenticatedForCompetition: () => false, isAuthenticated: false, - signIn: () => {}, + signIn: async () => {}, signOut: () => {}, }); diff --git a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx index dc191f9..4f1c4f4 100644 --- a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx @@ -1,17 +1,18 @@ -import { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { PropsWithChildren, useCallback, useMemo, useState } from 'react'; import { clearNotifyCompRemoteToken, - consumeNotifyCompRemoteRedirectPath, getNotifyCompRemoteClaims, getNotifyCompRemoteToken, - isNotifyCompRemoteAuthPending, - setNotifyCompRemoteAuthPending, + hasNotifyCompRemoteTokenForCompetition, setNotifyCompRemoteToken, } from '@/lib/notifyCompRemoteAuth'; -import { NOTIFYCOMP_AUTH_ORIGIN } from '@/lib/remoteConfig'; +import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken'; +import { useAuth } from '../AuthProvider'; import { NotifyCompRemoteAuthContext } from './NotifyCompRemoteAuthContext'; +const NOTIFY_COMP_TOKEN_URL = '/.netlify/functions/notify-comp-token'; +const REMOTE_SCOPE = 'notifycomp.remote'; + const readErrorMessage = async (response: Response) => { const text = await response.text(); @@ -27,75 +28,59 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { const [token, setToken] = useState(getNotifyCompRemoteToken); const [authenticating, setAuthenticating] = useState(false); const [error, setError] = useState(null); - const location = useLocation(); - const navigate = useNavigate(); - - const signIn = useCallback(() => { - const redirectPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; - setNotifyCompRemoteAuthPending(redirectPath); - setError(null); - - const params = new URLSearchParams({ - redirect_uri: window.location.href, - }); - - window.location.href = `${NOTIFYCOMP_AUTH_ORIGIN}/auth/wca?${params.toString()}`; - }, []); - - const signOut = useCallback(() => { - clearNotifyCompRemoteToken(); - setToken(null); - }, []); - - useEffect(() => { - const params = new URLSearchParams(location.search); - const code = params.get('code'); + const { signIn: signInWithWca } = useAuth(); + + const signIn = useCallback( + async (competitionId: string) => { + setError(null); + const accessToken = getStoredWcaAccessToken(); + + if (!accessToken) { + signInWithWca(); + return; + } + + setAuthenticating(true); + + 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 (!code || !isNotifyCompRemoteAuthPending()) { - return; - } - - setAuthenticating(true); - setError(null); - - const callbackParams = new URLSearchParams({ - code, - redirect_uri: window.location.href, - }); - - fetch(`${NOTIFYCOMP_AUTH_ORIGIN}/auth/wca/callback?${callbackParams.toString()}`) - .then(async (response) => { if (!response.ok) { throw new Error(await readErrorMessage(response)); } - return (await response.json()) as { jwt?: string }; - }) - .then(({ jwt }) => { - if (!jwt) { - throw new Error('NotifyComp did not return a remote session token.'); + const payload = (await response.json()) as { token?: string }; + if (!payload.token) { + throw new Error('Remote token response was missing a token.'); } - setNotifyCompRemoteToken(jwt); - setToken(jwt); - - const nextParams = new URLSearchParams(location.search); - nextParams.delete('code'); - const query = nextParams.toString(); - const fallbackPath = `${location.pathname}${query ? `?${query}` : ''}${location.hash}`; - const redirectPath = consumeNotifyCompRemoteRedirectPath() || fallbackPath; - navigate(redirectPath, { replace: true }); - }) - .catch((err) => { - setError(err instanceof Error ? err.message : 'Unable to sign in to NotifyComp Remote.'); + setNotifyCompRemoteToken(payload.token); + setToken(payload.token); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to authorize NotifyComp Remote.'); clearNotifyCompRemoteToken(); setToken(null); - consumeNotifyCompRemoteRedirectPath(); - }) - .finally(() => { + } finally { setAuthenticating(false); - }); - }, [location, navigate]); + } + }, + [signInWithWca], + ); + + const signOut = useCallback(() => { + clearNotifyCompRemoteToken(); + setToken(null); + }, []); const claims = token ? getNotifyCompRemoteClaims() : null; @@ -103,6 +88,7 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { () => ({ authenticating, error, + isAuthenticatedForCompetition: hasNotifyCompRemoteTokenForCompetition, isAuthenticated: Boolean(token), signIn, signOut, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 633f6ff..22776a0 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,7 +6,6 @@ declare const __GIT_TAG__: string; interface ImportMetaEnv { readonly VITE_NOTIFY_COMP_ORIGIN?: string; - readonly VITE_NOTIFYCOMP_AUTH_ORIGIN?: string; readonly VITE_NOTIFYCOMP_API_ORIGIN?: string; readonly VITE_NOTIFYCOMP_WS_ORIGIN?: string; } From 32f4e2dfac722b42883b9e8f1ac131abfc41aa6c Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 17:49:48 -0700 Subject: [PATCH 03/31] add remote control bar --- .../NotifyCompRemoteBar.tsx | 75 +++++++++ src/components/NotifyCompRemoteBar/index.ts | 1 + src/components/index.ts | 1 + .../useCompetitionRemoteControl/index.ts | 1 + .../useCompetitionRemoteControl.ts | 94 +++++++++++ .../CompetitionLayout/CompetitionLayout.tsx | 5 +- src/lib/notifyCompRemoteActivities.test.ts | 46 ++++++ src/lib/notifyCompRemoteActivities.ts | 55 +++++++ .../Competition/Remote/RemoteActivityList.tsx | 150 ++++++++++++------ src/pages/Competition/Remote/index.tsx | 72 +++------ 10 files changed, 400 insertions(+), 100 deletions(-) create mode 100644 src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx create mode 100644 src/components/NotifyCompRemoteBar/index.ts create mode 100644 src/hooks/useCompetitionRemoteControl/index.ts create mode 100644 src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx new file mode 100644 index 0000000..a147613 --- /dev/null +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -0,0 +1,75 @@ +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/Button'; +import { Container } from '@/components/Container'; +import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; + +interface NotifyCompRemoteBarProps { + competitionId: string; +} + +const groupLabel = (count: number) => `${count} active activit${count === 1 ? 'y' : 'ies'}`; + +export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) { + const remote = useCompetitionRemoteControl({ competitionId }); + + if (!remote.isAuthenticated || remote.scheduledActivities.length === 0) { + return null; + } + + const activeNames = remote.activeGroups.map((group) => group.name); + const title = activeNames.length > 0 ? activeNames.join(', ') : 'No active activities'; + const detail = + activeNames.length > 0 + ? groupLabel(activeNames.length) + : remote.nextGroup + ? `Next: ${remote.nextGroup.name}` + : 'Remote overview'; + + const runSwitch = (direction: 'previous' | 'next') => { + const group = direction === 'previous' ? remote.previousGroup : remote.nextGroup; + void remote.switchToGroup(group); + }; + + 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/index.ts b/src/components/index.ts index 209c315..1c31153 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,4 +16,5 @@ export * from './LastFetchedAt'; export * from './LinkButton'; export * from './LoggedOutPromptCard'; export * from './Notebox'; +export * from './NotifyCompRemoteBar'; export * from './PinCompetitionButton'; 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.ts b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts new file mode 100644 index 0000000..416b7f0 --- /dev/null +++ b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities'; +import { + getRemoteActiveGroups, + getRemoteActivityGroups, + getRemoteActivityStates, + getRemoteNextGroup, + getRemotePreviousGroup, + getRemoteScheduledActivities, + RemoteActivityGroup, +} from '@/lib/notifyCompRemoteActivities'; +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); + +export function useCompetitionRemoteControl({ + competitionId, + enabled = true, + roomId, +}: UseCompetitionRemoteControlParams) { + const { wcif } = useWCIF(); + const remoteAuth = useNotifyCompRemoteAuth(); + const isAuthenticated = remoteAuth.isAuthenticatedForCompetition(competitionId); + const isEnabled = enabled && isAuthenticated; + + 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 startGroup = (group: RemoteActivityGroup) => + remote.startActivities(activityIdsForGroup(group)); + const stopGroup = (group: RemoteActivityGroup) => + remote.stopActivities(activityIdsForGroup(group)); + const resetGroup = (group: RemoteActivityGroup) => + remote.resetActivities(activityIdsForGroup(group)); + + const switchToGroup = async (group?: RemoteActivityGroup) => { + if (!group) { + return; + } + + const currentActivityIds = activeGroups.flatMap(activityIdsForGroup); + + if (currentActivityIds.length > 0) { + await remote.stopActivities(currentActivityIds); + } + + await remote.startActivities(activityIdsForGroup(group)); + }; + + return { + ...remote, + activeGroups, + activityGroups, + activityStates, + isAuthenticated, + nextGroup, + previousGroup, + resetGroup, + scheduledActivities, + startGroup, + stopGroup, + switchToGroup, + }; +} 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/notifyCompRemoteActivities.test.ts b/src/lib/notifyCompRemoteActivities.test.ts index 58f3c70..b0a2262 100644 --- a/src/lib/notifyCompRemoteActivities.test.ts +++ b/src/lib/notifyCompRemoteActivities.test.ts @@ -1,6 +1,8 @@ import { getRemoteActivityGroups, getRemoteActivityState, + getRemoteNextGroup, + getRemotePreviousGroup, RemoteScheduledActivity, } from './notifyCompRemoteActivities'; @@ -71,4 +73,48 @@ describe('notifyCompRemoteActivities', () => { expect(groups).toHaveLength(1); expect(groups[0].scheduledActivities.map((candidate) => candidate.id)).toEqual([201, 202]); }); + + 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 index ae8b65b..6b6b152 100644 --- a/src/lib/notifyCompRemoteActivities.ts +++ b/src/lib/notifyCompRemoteActivities.ts @@ -112,6 +112,61 @@ export const getRemoteActivityGroups = ( }); }; +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'), diff --git a/src/pages/Competition/Remote/RemoteActivityList.tsx b/src/pages/Competition/Remote/RemoteActivityList.tsx index 8b80cd0..951ce35 100644 --- a/src/pages/Competition/Remote/RemoteActivityList.tsx +++ b/src/pages/Competition/Remote/RemoteActivityList.tsx @@ -4,6 +4,7 @@ import { useNow } from '@/hooks/useNow'; import { RemoteActivityGroup, RemoteActivityState, + splitRemoteActivityGroups, splitRemoteActivityStates, } from '@/lib/notifyCompRemoteActivities'; import { formatTime } from '@/lib/time'; @@ -34,6 +35,7 @@ interface RemoteActivityListProps { onResetActivity: (state: RemoteActivityState) => void; onStartActivity: (state: RemoteActivityState) => void; onStopActivity: (state: RemoteActivityState) => void; + onToggleActivity: (state: RemoteActivityState) => void; states: RemoteActivityState[]; } @@ -42,6 +44,7 @@ export function RemoteActivityList({ onResetActivity, onStartActivity, onStopActivity, + onToggleActivity, states, }: RemoteActivityListProps) { const now = useNow(); @@ -59,11 +62,11 @@ export function RemoteActivityList({ { emptyText: 'No upcoming activities remain for this room.', items: groupedStates.next, - title: 'Next', + title: 'Upcoming', }, { items: groupedStates.done, - title: 'Done', + title: 'Over', }, ]; @@ -80,12 +83,18 @@ export function RemoteActivityList({
-
-
{state.scheduledActivity.name}
-
- {state.scheduledActivity.room.name} - {stateDescription(state, now)} +
+
{state.status === 'current' ? ( - ) : ( - - )} - -
+ return ( +
+ +
+ {group.status === 'current' || group.status === 'mixed' ? ( + + ) : ( + + )} + +
+
+ ); + })}
- ); - })} + + ))}
); } diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx index b89464e..412a225 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -3,22 +3,13 @@ import { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { BarLoader } from 'react-spinners'; import { Button, Container, NoteBox } from '@/components'; -import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities'; +import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; import { getRooms } from '@/lib/activities'; -import { - getRemoteActivityGroups, - getRemoteActivityStates, - getRemoteScheduledActivities, - RemoteActivityGroup, - RemoteActivityState, -} from '@/lib/notifyCompRemoteActivities'; +import { RemoteActivityGroup, RemoteActivityState } from '@/lib/notifyCompRemoteActivities'; import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; import { useWCIF } from '@/providers/WCIFProvider'; import { RemoteActivityList, RemoteGroupList } from './RemoteActivityList'; -const activityIdsForGroup = (group: RemoteActivityGroup) => - group.scheduledActivities.map((activity) => activity.id); - const confirmAction = (message: string) => window.confirm(message); export default function CompetitionRemote() { @@ -33,48 +24,30 @@ export default function CompetitionRemote() { const rooms = useMemo(() => (wcif ? getRooms(wcif) : []), [wcif]); const roomId = selectedRoomId === 'all' ? undefined : selectedRoomId; - const isRemoteAuthenticated = competitionId - ? remoteAuth.isAuthenticatedForCompetition(competitionId) - : false; - const remote = useNotifyCompRemoteActivities({ + const remote = useCompetitionRemoteControl({ competitionId: competitionId || '', - enabled: isRemoteAuthenticated, 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], - ); - if (!competitionId || !wcif) { return null; } const startActivity = (state: RemoteActivityState) => { - if (!confirmAction(`Start ${state.scheduledActivity.name}?`)) { - return; - } - void remote.startActivity(state.scheduledActivity.id); }; const stopActivity = (state: RemoteActivityState) => { - if (!confirmAction(`Stop ${state.scheduledActivity.name}?`)) { + void remote.stopActivity(state.scheduledActivity.id); + }; + + const toggleActivity = (state: RemoteActivityState) => { + if (state.status === 'current') { + stopActivity(state); return; } - void remote.stopActivity(state.scheduledActivity.id); + startActivity(state); }; const resetActivity = (state: RemoteActivityState) => { @@ -88,19 +61,20 @@ export default function CompetitionRemote() { }; const startGroup = (group: RemoteActivityGroup) => { - if (!confirmAction(`Start ${group.name} in all listed rooms?`)) { - return; - } - - void remote.startActivities(activityIdsForGroup(group)); + void remote.startGroup(group); }; const stopGroup = (group: RemoteActivityGroup) => { - if (!confirmAction(`Stop ${group.name} in all listed rooms?`)) { + void remote.stopGroup(group); + }; + + const toggleGroup = (group: RemoteActivityGroup) => { + if (group.status === 'current' || group.status === 'mixed') { + stopGroup(group); return; } - void remote.stopActivities(activityIdsForGroup(group)); + startGroup(group); }; const resetGroup = (group: RemoteActivityGroup) => { @@ -108,7 +82,7 @@ export default function CompetitionRemote() { return; } - void remote.resetActivities(activityIdsForGroup(group)); + void remote.resetGroup(group); }; return ( @@ -123,7 +97,7 @@ export default function CompetitionRemote() { {remoteAuth.error && } - {!isRemoteAuthenticated ? ( + {!remote.isAuthenticated ? (

Remote authorization

@@ -210,18 +184,20 @@ export default function CompetitionRemote() { {selectedRoomId === 'all' ? ( ) : ( )}
From b51aefda955f8585994649ea298927e1f3481671 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 17:54:19 -0700 Subject: [PATCH 04/31] make remote bar a horizontal player --- .../NotifyCompRemoteBar.tsx | 120 +++++++++++++----- 1 file changed, 89 insertions(+), 31 deletions(-) diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx index a147613..e123512 100644 --- a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -1,6 +1,5 @@ import classNames from 'classnames'; import { Link } from 'react-router-dom'; -import { Button } from '@/components/Button'; import { Container } from '@/components/Container'; import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; @@ -9,6 +8,10 @@ interface NotifyCompRemoteBarProps { } const groupLabel = (count: number) => `${count} active activit${count === 1 ? 'y' : 'ies'}`; +const iconButtonClassName = + 'flex h-8 w-8 items-center justify-center rounded-full text-base leading-none text-gray-200 hover-transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-40 md:h-9 md:w-9 md:text-lg'; +const primaryButtonClassName = + 'flex h-9 w-9 items-center justify-center rounded-full bg-white text-base leading-none text-gray-950 hover-transition hover:scale-105 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:scale-100 md:h-10 md:w-10 md:text-lg'; export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) { const remote = useCompetitionRemoteControl({ competitionId }); @@ -25,50 +28,105 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) : remote.nextGroup ? `Next: ${remote.nextGroup.name}` : 'Remote overview'; + const completedGroups = remote.activityGroups.filter((group) => group.status === 'done').length; + const progress = + remote.activityGroups.length > 0 ? (completedGroups / remote.activityGroups.length) * 100 : 0; const runSwitch = (direction: 'previous' | 'next') => { const group = direction === 'previous' ? remote.previousGroup : remote.nextGroup; void remote.switchToGroup(group); }; + const togglePlayback = () => { + if (remote.activeGroups.length > 0) { + void Promise.all(remote.activeGroups.map((group) => remote.stopGroup(group))); + return; + } + + void remote.switchToGroup(remote.nextGroup); + }; return ( ); From 46935a4d4d11e89b097ce25aa2f15f718a3fe09a Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 17:55:53 -0700 Subject: [PATCH 05/31] flatten remote page layout --- src/pages/Competition/Remote/index.tsx | 88 +++++++++----------------- 1 file changed, 31 insertions(+), 57 deletions(-) diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx index 412a225..130ae51 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -88,41 +88,22 @@ export default function CompetitionRemote() { return (
-
-

Remote

-

- Control live activity status for {wcif.shortName || wcif.name}. -

-
- {remoteAuth.error && } {!remote.isAuthenticated ? ( -
-
-

Remote authorization

-

- Authorize this competition with your WCA account to start, stop, reset, or - auto-advance activities. -

-
- -
+ ) : ( <> -
-
-

Remote session

-

- {remoteAuth.userName ? `Signed in as ${remoteAuth.userName}` : 'Signed in'} -

+
+
+ {remoteAuth.userName ? `Signed in as ${remoteAuth.userName}` : 'Signed in'}
+
+

+ Sign in to NotifyComp Remote with your WCA account to connect these controls to this + competition; signing in does not import the competition or start any activities. +

+ +
) : ( <>
@@ -118,69 +124,80 @@ export default function CompetitionRemote() { {remote.isLoading && } {remote.error && } -
-
-
- + {!remote.isLoading && !remote.competition ? ( + + ) : ( +
+
+
+ +
+
- -
-
- {selectedRoomId === 'all' ? ( - - ) : ( - - )} +
+ {selectedRoomId === 'all' ? ( + + ) : ( + + )} +
-
+ )} )}
From 364aa649801c6ea3f03aa763a235c198017b63ef Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 18:03:41 -0700 Subject: [PATCH 08/31] move notifycomp sign out to settings --- src/pages/Competition/Remote/index.tsx | 9 --------- src/pages/Settings/index.tsx | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx index 776d175..d499ac3 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -112,15 +112,6 @@ export default function CompetitionRemote() {
) : ( <> -
-
- {remoteAuth.userName ? `Signed in as ${remoteAuth.userName}` : 'Signed in'} -
- -
- {remote.isLoading && } {remote.error && } diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index bed7b32..bd6517f 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}

}
+ +
+
+

NotifyComp Remote

+

+ Manage the NotifyComp Remote session used by competition remote controls. +

+

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

+
+ + {notifyCompRemoteAuth.isAuthenticated && ( + + )} +
); From 6eb10f57b9e3a05dc1e901b005d38398b2cd788e Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 18:05:12 -0700 Subject: [PATCH 09/31] theme remote bar for competitiongroups --- .../NotifyCompRemoteBar.tsx | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx index cb31754..9a06169 100644 --- a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -9,9 +9,9 @@ interface NotifyCompRemoteBarProps { const groupLabel = (count: number) => `${count} active activit${count === 1 ? 'y' : 'ies'}`; const iconButtonClassName = - 'flex h-8 w-8 items-center justify-center rounded-full text-base leading-none text-gray-200 hover-transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-40 md:h-9 md:w-9 md:text-lg'; + 'flex h-8 w-8 items-center justify-center rounded-full border border-tertiary-weak bg-panel text-base leading-none text-default shadow-sm hover-transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-700 md:h-9 md:w-9 md:text-lg'; const primaryButtonClassName = - 'flex h-9 w-9 items-center justify-center rounded-full bg-white text-base leading-none text-gray-950 hover-transition hover:scale-105 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:scale-100 md:h-10 md:w-10 md:text-lg'; + 'flex h-9 w-9 items-center justify-center rounded-full border border-blue-300 bg-blue-200 text-base 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 md:h-10 md:w-10 md:text-lg'; export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) { const remote = useCompetitionRemoteControl({ competitionId }); @@ -48,17 +48,17 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) return ( diff --git a/src/lib/notifyCompRemoteActivities.test.ts b/src/lib/notifyCompRemoteActivities.test.ts index b0a2262..7fd78ea 100644 --- a/src/lib/notifyCompRemoteActivities.test.ts +++ b/src/lib/notifyCompRemoteActivities.test.ts @@ -53,13 +53,13 @@ describe('notifyCompRemoteActivities', () => { ).toBe('done'); }); - it('groups matching activity codes across rooms by round code', () => { + 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-g2', + activityCode: '333-r1-g1', room: { ...room, id: 2, @@ -74,6 +74,34 @@ describe('notifyCompRemoteActivities', () => { 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( [ diff --git a/src/lib/notifyCompRemoteActivities.ts b/src/lib/notifyCompRemoteActivities.ts index 6b6b152..81059ae 100644 --- a/src/lib/notifyCompRemoteActivities.ts +++ b/src/lib/notifyCompRemoteActivities.ts @@ -76,18 +76,21 @@ export const getRemoteActivityStates = ( liveActivities: NotifyCompActivity[], ) => scheduledActivities.map((activity) => getRemoteActivityState(activity, liveActivities)); -const normalizeActivityCode = (activity: Activity) => { +const getGroupActivityCode = (activity: Activity) => { const code = activity.activityCode || activity.name; - return code.replace(/-g\d+$/i, ''); + 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 = normalizeActivityCode(activity); + const key = getGroupKey(activity); acc[key] = [...(acc[key] || []), activity]; return acc; }, @@ -104,7 +107,7 @@ export const getRemoteActivityGroups = ( return { id, - name: activities[0]?.parent?.name || activities[0]?.name || id, + name: activities[0]?.name || activities[0]?.parent?.name || id, scheduledActivities: activities, liveActivities: groupLiveActivities, status: statuses.size === 1 ? states[0].status : 'mixed', diff --git a/src/pages/Competition/Remote/RemoteActionDialog.tsx b/src/pages/Competition/Remote/RemoteActionDialog.tsx index 389ade6..183b9b5 100644 --- a/src/pages/Competition/Remote/RemoteActionDialog.tsx +++ b/src/pages/Competition/Remote/RemoteActionDialog.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components'; -export type RemoteAction = 'start' | 'stop'; +export type RemoteAction = 'reset' | 'start' | 'stop'; interface RemoteActionDialogProps { action: RemoteAction; @@ -19,7 +19,12 @@ export function RemoteActionDialog({ onConfirm, roomNames, }: RemoteActionDialogProps) { - const actionLabel = action === 'start' ? 'Start' : 'Stop'; + const actionLabel = { + reset: 'Reset', + start: 'Start', + stop: 'Stop', + }[action]; + const buttonVariant = action === 'start' ? 'green' : 'gray'; return (
+ {action === 'reset' && ( +

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

+ )} +
-
diff --git a/src/pages/Competition/Remote/RemoteActivityList.tsx b/src/pages/Competition/Remote/RemoteActivityList.tsx index b2aa59a..2878f1a 100644 --- a/src/pages/Competition/Remote/RemoteActivityList.tsx +++ b/src/pages/Competition/Remote/RemoteActivityList.tsx @@ -1,13 +1,17 @@ +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 } from '@/lib/time'; +import { formatTime, formatTimeRange } from '@/lib/time'; const formatStartedDuration = (startTime: string, now: Date) => { const { hours, minutes } = intervalToDuration({ @@ -30,7 +34,21 @@ const stateDescription = (state: RemoteActivityState, now: Date) => { return `Should start at ${formatTime(state.scheduledActivity.startTime)}`; }; -const activityCountText = (count: number) => `${count} ${count === 1 ? 'activity' : 'activities'}`; +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; interface RemoteActivityListProps { disabled?: boolean; @@ -136,24 +154,48 @@ export function RemoteGroupList({ disabled, groups, onSelectGroup }: RemoteGroup
{section.items.map((group) => { const activityIds = group.scheduledActivities.map((activity) => activity.id); - const rooms = [ - ...new Set(group.scheduledActivities.map((activity) => activity.room.name)), - ]; + const firstActivity = group.scheduledActivities[0]; + const rooms = groupRooms(group); return ( ); })} diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx index d088913..474502c 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -1,15 +1,14 @@ import classNames from 'classnames'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { BarLoader } from 'react-spinners'; import { Button, Container, NoteBox } from '@/components'; import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; -import { getRooms } from '@/lib/activities'; -import { RemoteActivityState } from '@/lib/notifyCompRemoteActivities'; +import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities'; import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; import { useWCIF } from '@/providers/WCIFProvider'; import { RemoteAction, RemoteActionDialog } from './RemoteActionDialog'; -import { RemoteActivityList } from './RemoteActivityList'; +import { RemoteGroupList } from './RemoteActivityList'; const confirmAction = (message: string) => window.confirm(message); @@ -24,43 +23,46 @@ export default function CompetitionRemote() { const { competitionId } = useParams<{ competitionId: string }>(); const { wcif, setTitle } = useWCIF(); const remoteAuth = useNotifyCompRemoteAuth(); - const [selectedRoomId, setSelectedRoomId] = useState(null); const [pendingAction, setPendingAction] = useState(null); useEffect(() => { setTitle('Remote'); }, [setTitle]); - const rooms = useMemo(() => (wcif ? getRooms(wcif) : []), [wcif]); - const roomId = selectedRoomId ?? rooms[0]?.id; const remote = useCompetitionRemoteControl({ competitionId: competitionId || '', - roomId, }); if (!competitionId || !wcif) { return null; } - const selectActivity = (state: RemoteActivityState) => { - if (state.status === 'done') { - return; - } - - const action = state.status === 'current' ? 'stop' : 'start'; + const selectGroup = (group: RemoteActivityGroup) => { + const action = + group.status === 'done' + ? 'reset' + : group.status === 'current' || group.status === 'mixed' + ? 'stop' + : 'start'; + const roomNames = [...new Set(group.scheduledActivities.map((activity) => activity.room.name))]; setPendingAction({ action, - activityName: state.scheduledActivity.name, + activityName: group.name, onConfirm: () => { if (action === 'start') { - void remote.startActivity(state.scheduledActivity.id); + void remote.startGroup(group); + return; + } + + if (action === 'reset') { + void remote.resetGroup(group); return; } - void remote.stopActivity(state.scheduledActivity.id); + void remote.stopGroup(group); }, - roomNames: [state.scheduledActivity.room.name], + roomNames, }); }; @@ -106,19 +108,7 @@ export default function CompetitionRemote() { ) : (
-
- -
+

All rooms

From b5a2f4e2fe7b4c219bae21da1475fbbc488bee94 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 18:26:34 -0700 Subject: [PATCH 15/31] Use font awesome remote nav arrows --- src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx index bd314f6..0cb90c8 100644 --- a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -80,7 +80,7 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) disabled={remote.isSaving || !remote.previousGroup} aria-label="Go back to previous remote activity" onClick={() => runSwitch('previous')}> - ⏮ +
From 75e726891d850d351bf6dad3dc99b261d1bb4568 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 18:28:51 -0700 Subject: [PATCH 16/31] Replace auto advance button with toggle --- .../NotifyCompRemoteBar.tsx | 9 +---- .../Remote/RemoteAutoAdvanceToggle.tsx | 34 +++++++++++++++++++ src/pages/Competition/Remote/index.tsx | 13 ++++--- 3 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx index 0cb90c8..ce05265 100644 --- a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -122,14 +122,7 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
-
-
-
Remote
-
- {remote.error ? remote.error : remote.isSaving ? 'Syncing' : 'Ready'} -
-
-
+
); diff --git a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx new file mode 100644 index 0000000..fe10a70 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx @@ -0,0 +1,34 @@ +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 index 474502c..871e007 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -9,6 +9,7 @@ import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvide import { useWCIF } from '@/providers/WCIFProvider'; import { RemoteAction, RemoteActionDialog } from './RemoteActionDialog'; import { RemoteGroupList } from './RemoteActivityList'; +import { RemoteAutoAdvanceToggle } from './RemoteAutoAdvanceToggle'; const confirmAction = (message: string) => window.confirm(message); @@ -109,11 +110,10 @@ export default function CompetitionRemote() {

All rooms

- + }} + />
Date: Mon, 11 May 2026 18:31:25 -0700 Subject: [PATCH 17/31] Extend remote token lifetime --- netlify/functions/notify-comp-token.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netlify/functions/notify-comp-token.js b/netlify/functions/notify-comp-token.js index 8c3b867..978dc5d 100644 --- a/netlify/functions/notify-comp-token.js +++ b/netlify/functions/notify-comp-token.js @@ -10,6 +10,8 @@ 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 signJwt = (claims, secret) => { const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); @@ -89,6 +91,8 @@ exports.handler = async (event) => { } const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE; + const tokenTtlSeconds = + tokenScope === REMOTE_SCOPE ? REMOTE_TOKEN_TTL_SECONDS : PUSH_TOKEN_TTL_SECONDS; if (tokenScope === REMOTE_SCOPE && !competitionId) { return { statusCode: 400, @@ -102,7 +106,7 @@ exports.handler = async (event) => { { aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp', competitionIds: tokenScope === REMOTE_SCOPE ? [competitionId] : undefined, - exp: now + 10 * 60, + exp: now + tokenTtlSeconds, iat: now, iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com', name: me.name, From f57e51f05ee428e4206a8299d463a145acc5c99e Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 18:33:19 -0700 Subject: [PATCH 18/31] Fix remote auto advance toggle styling --- src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx index fe10a70..05df307 100644 --- a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx +++ b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx @@ -20,12 +20,12 @@ export function RemoteAutoAdvanceToggle({ disabled={disabled} className={classNames( 'relative h-6 w-12 rounded-full shadow-sm hover-transition disabled:cursor-not-allowed disabled:opacity-50', - checked ? 'bg-blue-400' : 'bg-gray-300 dark:bg-gray-600', + checked ? 'bg-blue-300' : 'bg-gray-300 dark:bg-gray-600', )} onClick={onToggle}> From 8e538f179ef3f19c093fde0bebcd26b84c741b3d Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 18:36:40 -0700 Subject: [PATCH 19/31] Align remote toggle with app theme --- .../Competition/Remote/RemoteAutoAdvanceToggle.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx index 05df307..5b04f32 100644 --- a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx +++ b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.tsx @@ -19,14 +19,16 @@ export function RemoteAutoAdvanceToggle({ aria-label="Auto-advance" disabled={disabled} className={classNames( - 'relative h-6 w-12 rounded-full shadow-sm hover-transition disabled:cursor-not-allowed disabled:opacity-50', - checked ? 'bg-blue-300' : 'bg-gray-300 dark:bg-gray-600', + 'relative inline-flex h-7 w-12 rounded-full border p-0.5 shadow-sm hover-transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-50', + checked + ? 'border-primary bg-primary hover:bg-primary-strong' + : 'border-tertiary bg-tertiary hover:bg-tertiary-strong', )} onClick={onToggle}> From 6bb61d559b92082fe0743412be4dfc96b43848b1 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 18:38:26 -0700 Subject: [PATCH 20/31] Label remote auto advance toggle --- src/pages/Competition/Remote/index.tsx | 29 ++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx index 871e007..a47357a 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -110,19 +110,22 @@ export default function CompetitionRemote() {

All rooms

- { - if ( - confirmAction( - `${remote.autoAdvance ? 'Disable' : 'Enable'} auto-advance for this competition?`, - ) - ) { - void remote.updateAutoAdvance(!remote.autoAdvance); - } - }} - /> +
+ Auto-advance + { + if ( + confirmAction( + `${remote.autoAdvance ? 'Disable' : 'Enable'} auto-advance for this competition?`, + ) + ) { + void remote.updateAutoAdvance(!remote.autoAdvance); + } + }} + /> +
Date: Mon, 11 May 2026 18:43:08 -0700 Subject: [PATCH 21/31] Center remote control group --- .../NotifyCompRemoteBar/NotifyCompRemoteBar.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx index ce05265..919d761 100644 --- a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -62,17 +62,17 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) ); From e18851ec886078122a2112022f7832101c182345 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 18:45:12 -0700 Subject: [PATCH 22/31] Center remote bar content in full width --- src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx index 919d761..880ee5d 100644 --- a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -62,7 +62,9 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) diff --git a/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts b/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts index 9c414bb..ad2f465 100644 --- a/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts +++ b/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts @@ -1,7 +1,7 @@ import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities'; import { formatElapsedMMSS, - formatUntilNextActivity, + formatNextActivityOffset, getRemoteBarProgress, } from './remoteBarProgress'; @@ -37,7 +37,7 @@ describe('remoteBarProgress', () => { it('describes how many minutes remain until the next activity', () => { expect( - formatUntilNextActivity( + formatNextActivityOffset( group({ name: 'Clock, Round 1, Group 1', scheduledActivities: [ @@ -49,7 +49,23 @@ describe('remoteBarProgress', () => { }), new Date('2026-06-01T10:10:00Z'), ), - ).toBe('5 minutes until Clock, Round 1, Group 1'); + ).toBe('In 5 minutes'); + }); + + 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 next scheduled start time', () => { diff --git a/src/components/NotifyCompRemoteBar/remoteBarProgress.ts b/src/components/NotifyCompRemoteBar/remoteBarProgress.ts index b709384..23de328 100644 --- a/src/components/NotifyCompRemoteBar/remoteBarProgress.ts +++ b/src/components/NotifyCompRemoteBar/remoteBarProgress.ts @@ -51,7 +51,7 @@ export const getActiveGroupStartTime = (groups: RemoteActivityGroup[]) => }), ); -export const formatUntilNextActivity = (nextGroup: RemoteActivityGroup | undefined, now: Date) => { +export const formatNextActivityOffset = (nextGroup: RemoteActivityGroup | undefined, now: Date) => { const nextStart = getGroupScheduledStartTime(nextGroup); if (!nextGroup || nextStart === undefined) { @@ -61,13 +61,20 @@ export const formatUntilNextActivity = (nextGroup: RemoteActivityGroup | undefin const remainingMs = nextStart - now.getTime(); if (remainingMs <= 0) { - return `${nextGroup.name} should start now`; + return 'Now'; } const remainingMinutes = Math.ceil(remainingMs / MINUTE); - const unit = remainingMinutes === 1 ? 'minute' : 'minutes'; - return `${remainingMinutes} ${unit} until ${nextGroup.name}`; + if (remainingMinutes < 60) { + const unit = remainingMinutes === 1 ? 'minute' : 'minutes'; + return `In ${remainingMinutes} ${unit}`; + } + + const remainingHours = Math.ceil(remainingMinutes / 60); + const unit = remainingHours === 1 ? 'hour' : 'hours'; + + return `In ${remainingHours} ${unit}`; }; export const getRemoteBarProgress = ({ From 915347bdbb0e6a3599b557f7986d8ecd3fec57b5 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 19:01:47 -0700 Subject: [PATCH 27/31] Increase remote bar touch targets --- src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx index 81f7e99..7e6b618 100644 --- a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -14,9 +14,9 @@ interface NotifyCompRemoteBarProps { } const iconButtonClassName = - 'flex h-8 w-8 items-center justify-center rounded-full border border-tertiary-weak bg-panel text-base leading-none text-default shadow-sm hover-transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-700 md:h-9 md:w-9 md:text-lg'; + '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-9 w-9 items-center justify-center rounded-full border border-blue-300 bg-blue-200 text-base 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 md:h-10 md:w-10 md:text-lg'; + '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'; const confirmNextGroup = (groupName: string) => window.confirm(`Advance to ${groupName}? This will update the live remote activity.`); @@ -88,7 +88,7 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
{elapsed}
-
+
diff --git a/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts b/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts index ad2f465..aa5ec41 100644 --- a/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts +++ b/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts @@ -1,5 +1,6 @@ import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities'; import { + formatElapsedDuration, formatElapsedMMSS, formatNextActivityOffset, getRemoteBarProgress, @@ -35,6 +36,26 @@ describe('remoteBarProgress', () => { ); }); + 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( @@ -68,7 +89,7 @@ describe('remoteBarProgress', () => { ).toBe('In 4 hours'); }); - it('fills progress based on live start time and next scheduled start time', () => { + it('fills progress based on live start time and scheduled activity duration', () => { expect( getRemoteBarProgress({ activeGroups: [ @@ -83,19 +104,57 @@ describe('remoteBarProgress', () => { status: 'current', }), ], - nextGroup: group({ - id: '222-r1-g1', - name: '2x2x2 Cube, Round 1, Group 1', - scheduledActivities: [ - { - ...group({}).scheduledActivities[0], - id: 102, - startTime: '2026-06-01T10:20:00Z', - }, - ], - }), now: new Date('2026-06-01T10:05:00Z'), }), - ).toBe(25); + ).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 index 23de328..2812980 100644 --- a/src/components/NotifyCompRemoteBar/remoteBarProgress.ts +++ b/src/components/NotifyCompRemoteBar/remoteBarProgress.ts @@ -19,6 +19,11 @@ const getEarliestTime = (times: Array) => { 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); @@ -36,6 +41,20 @@ export const formatElapsedMMSS = (startTime: number | string | undefined, now: D 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) => { @@ -51,6 +70,33 @@ export const getActiveGroupStartTime = (groups: RemoteActivityGroup[]) => }), ); +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); @@ -79,25 +125,31 @@ export const formatNextActivityOffset = (nextGroup: RemoteActivityGroup | undefi export const getRemoteBarProgress = ({ activeGroups, - nextGroup, now, }: { activeGroups: RemoteActivityGroup[]; - nextGroup?: RemoteActivityGroup; now: Date; }) => { const activeStart = getActiveGroupStartTime(activeGroups); - const nextStart = getGroupScheduledStartTime(nextGroup); - - if (activeStart === undefined || nextStart === undefined) { - return nextStart !== undefined && now.getTime() >= nextStart ? 100 : 0; - } - - const duration = nextStart - activeStart; + const duration = getActiveGroupsScheduledDuration(activeGroups); - if (duration <= 0) { - return now.getTime() >= nextStart ? 100 : 0; + if (activeStart === undefined || duration === undefined) { + return { + percent: 0, + tone: 'normal' as const, + }; } - return clampPercent(((now.getTime() - activeStart) / duration) * 100); + 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), + }; }; From 0c6ab0c2e5135598231cdfee370c550fce447576 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 19:08:02 -0700 Subject: [PATCH 29/31] Use minute rounding on remote schedule --- .../Competition/Remote/RemoteActivityList.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/pages/Competition/Remote/RemoteActivityList.tsx b/src/pages/Competition/Remote/RemoteActivityList.tsx index 2878f1a..9544cf3 100644 --- a/src/pages/Competition/Remote/RemoteActivityList.tsx +++ b/src/pages/Competition/Remote/RemoteActivityList.tsx @@ -13,6 +13,8 @@ import { } 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), @@ -23,15 +25,25 @@ const formatStartedDuration = (startTime: string, now: Date) => { }; 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) { - return `Ended at ${formatTime(state.liveActivity.endTime)}`; + return `Ended at ${formatTime( + state.liveActivity.endTime, + REMOTE_TIME_ROUNDING_MINUTES, + timeZone, + )}`; } - return `Should start at ${formatTime(state.scheduledActivity.startTime)}`; + return `Should start at ${formatTime( + state.scheduledActivity.startTime, + REMOTE_TIME_ROUNDING_MINUTES, + timeZone, + )}`; }; const groupActivityName = (activity: RemoteScheduledActivity) => @@ -190,7 +202,7 @@ export function RemoteGroupList({ disabled, groups, onSelectGroup }: RemoteGroup {formatTimeRange( firstActivity.startTime, firstActivity.endTime, - 5, + REMOTE_TIME_ROUNDING_MINUTES, groupTimeZone(firstActivity), )} From c42e7267e2506aaa638c6af764b435c8d1defbbe Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 19:10:22 -0700 Subject: [PATCH 30/31] Add off-day remote reset all action --- .../useNotifyCompRemoteActivities.ts | 6 +++++ src/pages/Competition/Remote/index.tsx | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts b/src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts index b894bb8..1d780b9 100644 --- a/src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts +++ b/src/hooks/useNotifyCompRemoteActivities/useNotifyCompRemoteActivities.ts @@ -160,6 +160,12 @@ export function useNotifyCompRemoteActivities({ variables: { competitionId, activityIds }, }), ), + resetAllActivities: () => + runMutation(() => + resetActivities({ + variables: { competitionId }, + }), + ), resetActivity: (activityId: number) => runMutation(() => resetActivity({ diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx index 1c6f338..037ff60 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom'; import { BarLoader } from 'react-spinners'; import { Button, Container, NoteBox } from '@/components'; import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; +import { isCompetitionDay } from '@/lib/competitionDates'; import { RemoteActivityGroup } from '@/lib/notifyCompRemoteActivities'; import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -38,6 +39,10 @@ export default function CompetitionRemote() { return null; } + const hasStartedRemoteActivities = remote.activities.some((activity) => activity.startTime); + const canResetAllActivities = + Boolean(remote.competition) && !isCompetitionDay(wcif) && hasStartedRemoteActivities; + const selectGroup = (group: RemoteActivityGroup) => { const action = group.status === 'done' @@ -140,6 +145,26 @@ export default function CompetitionRemote() { onSelectGroup={selectGroup} />
+ + {canResetAllActivities && ( +
+ +
+ )}
)} From d5a4d12aeb2c9fb643da1329dbe1a43ff697bf09 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 11 May 2026 21:27:53 -0700 Subject: [PATCH 31/31] Improve live activity remote controls --- .gitignore | 1 + netlify/functions/notify-comp-token.js | 63 +++- src/App.tsx | 15 +- src/apolloClient.ts | 19 ++ .../NotifyCompConnectionStatus.tsx | 47 +++ .../NotifyCompConnectionStatus/index.ts | 1 + .../NotifyCompRemoteBar.tsx | 100 ++++-- .../remoteBarProgress.test.ts | 16 + .../NotifyCompRemoteBar/remoteBarProgress.ts | 17 +- .../RemoteActivitySummaryList.stories.tsx | 60 ++++ .../RemoteActivitySummaryList.tsx | 49 +++ .../RemoteActivitySummaryList/index.ts | 1 + src/components/index.ts | 2 + .../CompetitionLive/CompetitionLive.tsx | 3 +- .../OngoingActivities/OngoingActivities.tsx | 15 +- src/containers/Schedule/Schedule.tsx | 44 +-- .../Schedule/ScheduleActivityRow.tsx | 54 +++ .../Schedule/scheduleActivityGroups.test.ts | 111 ++++++ .../Schedule/scheduleActivityGroups.ts | 117 +++++++ .../useCompetitionRemoteControl.test.ts | 184 ++++++++++ .../useCompetitionRemoteControl.ts | 130 +++++++- src/hooks/useNotifyCompWebSocketStatus.ts | 12 + .../CompetitionLayout.tabs.tsx | 9 +- src/lib/competitionAuthorization.test.ts | 31 ++ src/lib/competitionAuthorization.ts | 18 + src/lib/competitionDates.test.ts | 16 + src/lib/competitionDates.ts | 7 + src/lib/notifyCompRemoteAuth.test.ts | 14 +- src/lib/notifyCompRemoteAuth.ts | 10 +- src/lib/notifyCompWebSocketStatus.ts | 31 ++ src/lib/remoteConfig.ts | 2 +- .../Competition/Remote/RemoteActionDialog.tsx | 59 ---- .../Remote/RemoteActivityList.stories.tsx | 98 ++++++ .../Competition/Remote/RemoteActivityList.tsx | 60 +++- src/pages/Competition/Remote/index.tsx | 315 ++++++++++++------ src/pages/LiveActivities/About.tsx | 105 ++++++ src/pages/Settings/index.tsx | 6 +- .../ConfirmProvider/ConfirmContext.tsx | 16 + .../ConfirmProvider/ConfirmDialog.tsx | 55 +++ .../ConfirmProvider/ConfirmProvider.test.tsx | 65 ++++ .../ConfirmProvider/ConfirmProvider.tsx | 66 ++++ src/providers/ConfirmProvider/index.ts | 2 + .../NotifyCompRemoteAuthContext.tsx | 4 + .../NotifyCompRemoteAuthProvider.tsx | 10 +- 44 files changed, 1781 insertions(+), 279 deletions(-) create mode 100644 src/components/NotifyCompConnectionStatus/NotifyCompConnectionStatus.tsx create mode 100644 src/components/NotifyCompConnectionStatus/index.ts create mode 100644 src/components/RemoteActivitySummaryList/RemoteActivitySummaryList.stories.tsx create mode 100644 src/components/RemoteActivitySummaryList/RemoteActivitySummaryList.tsx create mode 100644 src/components/RemoteActivitySummaryList/index.ts create mode 100644 src/containers/Schedule/ScheduleActivityRow.tsx create mode 100644 src/containers/Schedule/scheduleActivityGroups.test.ts create mode 100644 src/containers/Schedule/scheduleActivityGroups.ts create mode 100644 src/hooks/useNotifyCompWebSocketStatus.ts create mode 100644 src/lib/competitionAuthorization.test.ts create mode 100644 src/lib/competitionAuthorization.ts create mode 100644 src/lib/competitionDates.test.ts create mode 100644 src/lib/notifyCompWebSocketStatus.ts delete mode 100644 src/pages/Competition/Remote/RemoteActionDialog.tsx create mode 100644 src/pages/Competition/Remote/RemoteActivityList.stories.tsx create mode 100644 src/pages/LiveActivities/About.tsx create mode 100644 src/providers/ConfirmProvider/ConfirmContext.tsx create mode 100644 src/providers/ConfirmProvider/ConfirmDialog.tsx create mode 100644 src/providers/ConfirmProvider/ConfirmProvider.test.tsx create mode 100644 src/providers/ConfirmProvider/ConfirmProvider.tsx create mode 100644 src/providers/ConfirmProvider/index.ts 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 978dc5d..2dd207a 100644 --- a/netlify/functions/notify-comp-token.js +++ b/netlify/functions/notify-comp-token.js @@ -13,6 +13,20 @@ 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' })); const encodedPayload = base64Url(JSON.stringify(claims)); @@ -58,6 +72,7 @@ exports.handler = async (event) => { } const { accessToken, competitionId, scope } = body; + const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE; if (!accessToken) { return { statusCode: 400, @@ -67,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}`, }, @@ -81,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, @@ -90,7 +107,6 @@ exports.handler = async (event) => { }; } - const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE; const tokenTtlSeconds = tokenScope === REMOTE_SCOPE ? REMOTE_TOKEN_TTL_SECONDS : PUSH_TOKEN_TTL_SECONDS; if (tokenScope === REMOTE_SCOPE && !competitionId) { @@ -101,11 +117,50 @@ exports.handler = async (event) => { }; } + 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', - competitionIds: tokenScope === REMOTE_SCOPE ? [competitionId] : undefined, + competitionIds: tokenScope === REMOTE_SCOPE ? remoteCompetitionIds : undefined, exp: now + tokenTtlSeconds, iat: now, iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com', diff --git a/src/App.tsx b/src/App.tsx index c0a0538..abaafe2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,12 +31,14 @@ 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'; @@ -129,6 +131,7 @@ const Navigation = () => { } /> } /> + } /> } /> } /> @@ -144,11 +147,13 @@ const App = () => ( - - - - - + + + + + + + diff --git a/src/apolloClient.ts b/src/apolloClient.ts index c750c54..4e33106 100644 --- a/src/apolloClient.ts +++ b/src/apolloClient.ts @@ -4,6 +4,7 @@ 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({ @@ -13,6 +14,24 @@ const httpLink = createHttpLink({ const wsLink = new GraphQLWsLink( createClient({ 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.', + }); + }, + }, }), ); 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 index de1146d..5611ba6 100644 --- a/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx +++ b/src/components/NotifyCompRemoteBar/NotifyCompRemoteBar.tsx @@ -1,7 +1,13 @@ 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, @@ -17,11 +23,10 @@ const iconButtonClassName = 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'; -const confirmNextGroup = (groupName: string) => - window.confirm(`Advance to ${groupName}? This will update the live remote activity.`); - 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) { @@ -29,6 +34,7 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) } 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); @@ -42,33 +48,82 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) '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 = (direction: 'previous' | 'next') => { + const runSwitch = async (direction: 'previous' | 'next') => { const group = direction === 'previous' ? remote.previousGroup : remote.nextGroup; if (direction === 'previous') { - void remote.switchToPreviousGroup(); + await remote.switchToPreviousGroup(); + return; + } + + if (direction === 'next' && !group && hasActiveGroups) { + if (await confirmFinishCompetition()) { + await remote.finishAllActivities(); + } return; } - if (direction === 'next' && group && !confirmNextGroup(group.name)) { + if (direction === 'next' && group && !(await confirmNextGroup(group))) { return; } - void remote.switchToGroup(group); + await remote.switchToGroup(group); }; - const togglePlayback = () => { + const togglePlayback = async () => { if (remote.activeGroups.length > 0) { - void Promise.all(remote.activeGroups.map((group) => remote.stopGroup(group))); + await Promise.all(remote.activeGroups.map((group) => remote.stopGroup(group))); return; } - if (remote.nextGroup && !confirmNextGroup(remote.nextGroup.name)) { + if (remote.nextGroup && !(await confirmNextGroup(remote.nextGroup))) { return; } - void remote.switchToGroup(remote.nextGroup); + await remote.switchToGroup(remote.nextGroup); }; return ( @@ -88,7 +143,7 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
Current
{currentTitle}
-
{elapsed}
+ {hasActiveGroups &&
{elapsed}
}
@@ -96,9 +151,11 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) @@ -106,23 +163,27 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps) type="button" className={primaryButtonClassName} disabled={ - remote.isSaving || (remote.activeGroups.length === 0 && !remote.nextGroup) + controlsDisabled || (remote.activeGroups.length === 0 && !remote.nextGroup) } aria-label={ remote.activeGroups.length > 0 ? 'Stop current remote activities' : 'Start next remote activity' } - onClick={togglePlayback}> + onClick={() => { + void togglePlayback(); + }}> {remote.activeGroups.length > 0 ? <>■ : <>▶}
@@ -139,6 +200,7 @@ export function NotifyCompRemoteBar({ competitionId }: NotifyCompRemoteBarProps)
{nextTitle}
{nextOffset}
+
diff --git a/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts b/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts index aa5ec41..dca0a94 100644 --- a/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts +++ b/src/components/NotifyCompRemoteBar/remoteBarProgress.test.ts @@ -73,6 +73,22 @@ describe('remoteBarProgress', () => { ).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( diff --git a/src/components/NotifyCompRemoteBar/remoteBarProgress.ts b/src/components/NotifyCompRemoteBar/remoteBarProgress.ts index 2812980..6761489 100644 --- a/src/components/NotifyCompRemoteBar/remoteBarProgress.ts +++ b/src/components/NotifyCompRemoteBar/remoteBarProgress.ts @@ -104,20 +104,19 @@ export const formatNextActivityOffset = (nextGroup: RemoteActivityGroup | undefi return 'No next activity'; } - const remainingMs = nextStart - now.getTime(); + const offsetMs = nextStart - now.getTime(); + const absoluteOffsetMinutes = Math.max(1, Math.ceil(Math.abs(offsetMs) / MINUTE)); + const minuteUnit = absoluteOffsetMinutes === 1 ? 'minute' : 'minutes'; - if (remainingMs <= 0) { - return 'Now'; + if (offsetMs < 0) { + return `${absoluteOffsetMinutes} ${minuteUnit} ago`; } - const remainingMinutes = Math.ceil(remainingMs / MINUTE); - - if (remainingMinutes < 60) { - const unit = remainingMinutes === 1 ? 'minute' : 'minutes'; - return `In ${remainingMinutes} ${unit}`; + if (absoluteOffsetMinutes < 60) { + return `In ${absoluteOffsetMinutes} ${minuteUnit}`; } - const remainingHours = Math.ceil(remainingMinutes / 60); + const remainingHours = Math.ceil(absoluteOffsetMinutes / 60); const unit = remainingHours === 1 ? 'hour' : 'hours'; return `In ${remainingHours} ${unit}`; 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 1c31153..38a0b25 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,5 +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/useCompetitionRemoteControl.test.ts b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts index 3894b6a..1694987 100644 --- a/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts +++ b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.test.ts @@ -1,5 +1,6 @@ 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'; @@ -7,6 +8,9 @@ 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(), })); @@ -35,6 +39,16 @@ const activity = ({ }); const wcif = { + persons: [ + { + roles: ['delegate'], + wcaUserId: 1, + }, + { + roles: ['staff-dataentry'], + wcaUserId: 2, + }, + ], schedule: { venues: [ { @@ -63,6 +77,48 @@ const wcif = { }, }; +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); @@ -70,6 +126,9 @@ describe('useCompetitionRemoteControl', () => { 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); @@ -109,4 +168,129 @@ describe('useCompetitionRemoteControl', () => { 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 index bd0ee66..6d9529e 100644 --- a/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts +++ b/src/hooks/useCompetitionRemoteControl/useCompetitionRemoteControl.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { useNotifyCompRemoteActivities } from '@/hooks/useNotifyCompRemoteActivities'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; import { getRemoteActiveGroups, getRemoteActivityGroups, @@ -9,6 +10,7 @@ import { getRemoteScheduledActivities, RemoteActivityGroup, } from '@/lib/notifyCompRemoteActivities'; +import { useAuth } from '@/providers/AuthProvider'; import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -21,15 +23,20 @@ interface UseCompetitionRemoteControlParams { 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; + const isEnabled = enabled && isAuthenticated && canManageRemote; const remote = useNotifyCompRemoteActivities({ competitionId, @@ -56,14 +63,105 @@ export function useCompetitionRemoteControl({ const previousGroup = useMemo(() => getRemotePreviousGroup(activityGroups), [activityGroups]); const nextGroup = useMemo(() => getRemoteNextGroup(activityGroups), [activityGroups]); - const startGroup = (group: RemoteActivityGroup) => - remote.startActivities(activityIdsForGroup(group)); - const stopGroup = (group: RemoteActivityGroup) => - remote.stopActivities(activityIdsForGroup(group)); - const resetGroup = (group: RemoteActivityGroup) => - remote.resetActivities(activityIdsForGroup(group)); + 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; } @@ -74,10 +172,16 @@ export function useCompetitionRemoteControl({ await remote.stopActivities(currentActivityIds); } + if (!isActiveGroup(group)) { + await finishSkippedGroupsBefore(group); + } + await remote.startActivities(activityIdsForGroup(group)); }; const switchToPreviousGroup = async () => { + requireRemoteAccess(); + if (!previousGroup) { return; } @@ -97,14 +201,26 @@ export function useCompetitionRemoteControl({ 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/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 8a09064..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; @@ -55,7 +58,9 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay href: `/competitions/${competitionId}/scramblers`, text: t('header.tabs.scramblers'), }); + } + if (canManageRemote) { _tabs.push({ href: `/competitions/${competitionId}/remote`, text: t('header.tabs.remote', { @@ -72,5 +77,5 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay } return _tabs; - }, [wcif, competitionId, user?.id, t]); + }, [wcif, competitionId, userId, t]); }; 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/notifyCompRemoteAuth.test.ts b/src/lib/notifyCompRemoteAuth.test.ts index a27ff52..5bc153f 100644 --- a/src/lib/notifyCompRemoteAuth.test.ts +++ b/src/lib/notifyCompRemoteAuth.test.ts @@ -1,7 +1,7 @@ import { clearNotifyCompRemoteToken, getNotifyCompRemoteClaims, - hasNotifyCompRemoteTokenForCompetition, + hasNotifyCompRemoteToken, setNotifyCompRemoteToken, } from './notifyCompRemoteAuth'; @@ -26,7 +26,7 @@ describe('notifyCompRemoteAuth', () => { clearNotifyCompRemoteToken(); }); - it('reads competitionIds from an unpadded remote JWT payload', () => { + it('reads an unpadded remote JWT payload as an authenticated session', () => { setNotifyCompRemoteToken( jwtWithClaims({ competitionIds: ['KentSpring2026'], @@ -36,18 +36,16 @@ describe('notifyCompRemoteAuth', () => { ); expect(getNotifyCompRemoteClaims()?.name).toBe('Test Delegate'); - expect(hasNotifyCompRemoteTokenForCompetition('KentSpring2026')).toBe(true); - expect(hasNotifyCompRemoteTokenForCompetition('OtherComp2026')).toBe(false); + expect(hasNotifyCompRemoteToken()).toBe(true); }); - it('accepts snake_case competition ids from remote JWT payloads', () => { + it('clears expired remote JWT payloads', () => { setNotifyCompRemoteToken( jwtWithClaims({ - competition_ids: ['KentSpring2026'], - exp: Math.floor(Date.now() / 1000) + 60, + exp: Math.floor(Date.now() / 1000) - 60, }), ); - expect(hasNotifyCompRemoteTokenForCompetition('KentSpring2026')).toBe(true); + expect(hasNotifyCompRemoteToken()).toBe(false); }); }); diff --git a/src/lib/notifyCompRemoteAuth.ts b/src/lib/notifyCompRemoteAuth.ts index c889896..d8d4f1a 100644 --- a/src/lib/notifyCompRemoteAuth.ts +++ b/src/lib/notifyCompRemoteAuth.ts @@ -48,15 +48,7 @@ export const getNotifyCompRemoteClaims = () => { return token ? decodeJwtPayload(token) : null; }; -export const hasNotifyCompRemoteTokenForCompetition = (competitionId: string) => { - const claims = getNotifyCompRemoteClaims(); - if (!claims) { - return false; - } - - const competitionIds = claims.competitionIds ?? claims.competition_ids ?? []; - return competitionIds.includes(competitionId); -}; +export const hasNotifyCompRemoteToken = () => Boolean(getNotifyCompRemoteClaims()); export const setNotifyCompRemoteToken = (token: string) => { setLocalStorage(REMOTE_JWT_KEY, token); 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 index eb927eb..77afa57 100644 --- a/src/lib/remoteConfig.ts +++ b/src/lib/remoteConfig.ts @@ -8,4 +8,4 @@ export const NOTIFYCOMP_GRAPHQL_ORIGIN = rawNotifyCompApiOrigin.endsWith('/graph export const NOTIFYCOMP_API_ORIGIN = NOTIFYCOMP_GRAPHQL_ORIGIN.replace(/\/graphql$/, ''); export const NOTIFYCOMP_WS_ORIGIN = - import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://api.notifycomp.com/api/graphql'; + import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || NOTIFYCOMP_GRAPHQL_ORIGIN.replace(/^http/, 'ws'); diff --git a/src/pages/Competition/Remote/RemoteActionDialog.tsx b/src/pages/Competition/Remote/RemoteActionDialog.tsx deleted file mode 100644 index 183b9b5..0000000 --- a/src/pages/Competition/Remote/RemoteActionDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Button } from '@/components'; - -export type RemoteAction = 'reset' | 'start' | 'stop'; - -interface RemoteActionDialogProps { - action: RemoteAction; - activityName: string; - disabled?: boolean; - onCancel: () => void; - onConfirm: () => void; - roomNames: string[]; -} - -export function RemoteActionDialog({ - action, - activityName, - disabled, - onCancel, - onConfirm, - roomNames, -}: RemoteActionDialogProps) { - const actionLabel = { - reset: 'Reset', - start: 'Start', - stop: 'Stop', - }[action]; - const buttonVariant = action === 'start' ? 'green' : 'gray'; - - return ( -
-
-

Are you sure?

- -

- This will {action} activity: {activityName} in rooms:{' '} - {roomNames.join(', ')} -

- - {action === 'reset' && ( -

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

- )} - -
- - -
-
-
- ); -} 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 index 9544cf3..7a0590d 100644 --- a/src/pages/Competition/Remote/RemoteActivityList.tsx +++ b/src/pages/Competition/Remote/RemoteActivityList.tsx @@ -24,6 +24,19 @@ const formatStartedDuration = (startTime: string, now: Date) => { 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); @@ -32,11 +45,16 @@ const stateDescription = (state: RemoteActivityState, now: Date) => { } 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( @@ -62,6 +80,28 @@ 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; @@ -168,6 +208,7 @@ export function RemoteGroupList({ disabled, groups, onSelectGroup }: RemoteGroup 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 (
) : ( <> {remote.isLoading && } {remote.error && } + {remote.competition && } {!remote.isLoading && !remote.competition ? ( - +
+ + +
+ ) : hasImportedEmptySchedule ? ( + ) : (

All rooms

-
- Auto-advance - { - if ( - confirmAction( - `${remote.autoAdvance ? 'Disable' : 'Enable'} auto-advance for this competition?`, - ) - ) { - void remote.updateAutoAdvance(!remote.autoAdvance); - } - }} - /> +
+
+
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); + } + })(); + }} + /> +
@@ -140,50 +229,76 @@ export default function CompetitionRemote() { 'opacity-60': remote.isSaving, })}> { + void selectGroup(group); + }} />
- {canResetAllActivities && ( + {(canResetAllActivities || canFinishAllActivities) && (
- + {canFinishAllActivities ? ( + + ) : ( + + )}
)} + + {hasFinishedAllStartedActivities && ( + + )}
)} )}
- - {pendingAction && ( - setPendingAction(null)} - onConfirm={() => { - pendingAction.onConfirm(); - setPendingAction(null); - }} - /> - )}
); } 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 bd6517f..87fef82 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -115,9 +115,9 @@ export default function Settings() {
-

NotifyComp Remote

+

Live Activities Remote

- Manage the NotifyComp Remote session used by competition remote controls. + Manage the separate session used by Live Activities remote controls.

{notifyCompRemoteAuth.isAuthenticated ? 'Signed in' : 'Not signed in'} @@ -126,7 +126,7 @@ export default function Settings() { {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 index dcac8be..0040c23 100644 --- a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext.tsx @@ -3,6 +3,10 @@ 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; diff --git a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx index 2b47a6a..b58f6a4 100644 --- a/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx +++ b/src/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthProvider.tsx @@ -4,7 +4,7 @@ import { clearNotifyCompRemoteToken, getNotifyCompRemoteClaims, getNotifyCompRemoteToken, - hasNotifyCompRemoteTokenForCompetition, + hasNotifyCompRemoteToken, setNotifyCompRemoteToken, } from '@/lib/notifyCompRemoteAuth'; import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken'; @@ -71,7 +71,9 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { setNotifyCompRemoteToken(payload.token); setToken(payload.token); } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to authorize NotifyComp Remote.'); + setError( + err instanceof Error ? err.message : 'Unable to authorize Live Activities Remote.', + ); clearNotifyCompRemoteToken(); setToken(null); } finally { @@ -87,7 +89,7 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { return; } - if (hasNotifyCompRemoteTokenForCompetition(pendingCompetitionId)) { + if (hasNotifyCompRemoteToken()) { deleteLocalStorage(PENDING_REMOTE_COMPETITION_ID_KEY); return; } @@ -110,7 +112,7 @@ export function NotifyCompRemoteAuthProvider({ children }: PropsWithChildren) { () => ({ authenticating, error, - isAuthenticatedForCompetition: hasNotifyCompRemoteTokenForCompetition, + isAuthenticatedForCompetition: hasNotifyCompRemoteToken, isAuthenticated: Boolean(token), signIn, signOut,