diff --git a/packages/web/app/components/graphql-queue/QueueContext.tsx b/packages/web/app/components/graphql-queue/QueueContext.tsx index 43828d26..966df31a 100644 --- a/packages/web/app/components/graphql-queue/QueueContext.tsx +++ b/packages/web/app/components/graphql-queue/QueueContext.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useContext, createContext, ReactNode, useCallback, useMemo, useState, useEffect } from 'react'; +import React, { useContext, createContext, ReactNode, useCallback, useMemo, useState, useEffect, useRef } from 'react'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { v4 as uuidv4 } from 'uuid'; import { useQueueReducer } from '../queue-control/reducer'; @@ -67,6 +67,14 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children }: G const sessionIdFromUrl = searchParams.get('session'); const [activeSessionId, setActiveSessionId] = useState(sessionIdFromUrl); + // Ref to store the initial queue to sync when starting a new session + // This captures the queue state before the connection overwrites it with backend state + const pendingInitialQueueRef = useRef<{ + queue: ClimbQueueItem[]; + currentClimbQueueItem: ClimbQueueItem | null; + sessionId: string; + } | null>(null); + // Sync activeSessionId with URL changes (e.g., when navigating to a shared link) useEffect(() => { // Only update activeSessionId from URL if: @@ -240,6 +248,33 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children }: G return unsubscribe; }, [isPersistentSessionActive, persistentSession, dispatch]); + // Extract connection state values that we need for the sync effect + // These must be extracted before the effect so they can be used as dependencies + const hasConnectedForSync = persistentSession.hasConnected; + const isLeaderForSync = persistentSession.isLeader; + + // Sync pending initial queue after connecting to a new session + // This handles the case where user starts a session with existing queue items + useEffect(() => { + // Only run when we've just connected to a session AND we're the leader + // All conditions must be met before we attempt the sync + if (!isPersistentSessionActive || !hasConnectedForSync || !isLeaderForSync) return; + + // Check if we have a pending initial queue for this session + const pending = pendingInitialQueueRef.current; + if (!pending || pending.sessionId !== sessionId) return; + + // Clear the pending ref immediately to prevent duplicate syncs + pendingInitialQueueRef.current = null; + + // Sync the initial queue to the server + // This will broadcast a FullSync event to all clients (including ourselves) + console.log('[QueueContext] Syncing initial queue to server:', pending.queue.length, 'items'); + persistentSession.setQueue(pending.queue, pending.currentClimbQueueItem).catch((error) => { + console.error('[QueueContext] Failed to sync initial queue:', error); + }); + }, [isPersistentSessionActive, hasConnectedForSync, isLeaderForSync, sessionId, persistentSession]); + // Use persistent session values when active const clientId = isPersistentSessionActive ? persistentSession.clientId : null; const isLeader = isPersistentSessionActive ? persistentSession.isLeader : false; @@ -257,6 +292,18 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children }: G // Generate a new session ID const newSessionId = uuidv4(); + // IMPORTANT: Capture the current queue BEFORE the connection is established. + // When we connect, the backend will return an empty queue (new session), + // which would overwrite our local queue. We store it here so we can sync it + // to the server after connecting. + if (state.queue.length > 0 || state.currentClimbQueueItem) { + pendingInitialQueueRef.current = { + queue: state.queue, + currentClimbQueueItem: state.currentClimbQueueItem, + sessionId: newSessionId, + }; + } + // Update URL with session parameter const params = new URLSearchParams(searchParams.toString()); params.set('session', newSessionId); @@ -277,7 +324,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children }: G return newSessionId; }, - [backendUrl, pathname, router, searchParams], + [backendUrl, pathname, router, searchParams, state.queue, state.currentClimbQueueItem], ); const joinSessionHandler = useCallback( diff --git a/packages/web/app/components/persistent-session/persistent-session-context.tsx b/packages/web/app/components/persistent-session/persistent-session-context.tsx index e06403b2..835825d0 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -211,10 +211,20 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> // Handle queue events internally const handleQueueEvent = useCallback((event: ClientQueueEvent) => { switch (event.__typename) { - case 'FullSync': - setQueueState(event.state.queue as LocalClimbQueueItem[]); - setCurrentClimbQueueItem(event.state.currentClimbQueueItem as LocalClimbQueueItem | null); + case 'FullSync': { + const newQueue = event.state.queue as LocalClimbQueueItem[]; + let newCurrentClimbQueueItem = event.state.currentClimbQueueItem as LocalClimbQueueItem | null; + + // Validate that current climb is in the queue + // If not, clear it to prevent inconsistent state + if (newCurrentClimbQueueItem && !newQueue.some(item => item.uuid === newCurrentClimbQueueItem?.uuid)) { + newCurrentClimbQueueItem = null; + } + + setQueueState(newQueue); + setCurrentClimbQueueItem(newCurrentClimbQueueItem); break; + } case 'QueueItemAdded': setQueueState((prev) => { const newQueue = [...prev]; @@ -228,6 +238,8 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> break; case 'QueueItemRemoved': setQueueState((prev) => prev.filter((item) => item.uuid !== event.uuid)); + // Clear current climb if it was removed + setCurrentClimbQueueItem((prev) => prev?.uuid === event.uuid ? null : prev); break; case 'QueueReordered': setQueueState((prev) => { @@ -510,9 +522,10 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> const deactivateSession = useCallback(() => { if (DEBUG) console.log('[PersistentSession] Deactivating session'); + // Only clear the active session, NOT the queue state. + // The queue should persist for local use after leaving party mode. + // The queue state will be synced to local queue by the QueueContext effect. setActiveSession(null); - setQueueState([]); - setCurrentClimbQueueItem(null); }, []); // Local queue management functions diff --git a/packages/web/app/components/queue-control/reducer.ts b/packages/web/app/components/queue-control/reducer.ts index 37d1d303..d2ba16f3 100644 --- a/packages/web/app/components/queue-control/reducer.ts +++ b/packages/web/app/components/queue-control/reducer.ts @@ -41,20 +41,40 @@ export function queueReducer(state: QueueState, action: QueueAction): QueueState ...state, climbSearchParams: action.payload, }; - case 'INITIAL_QUEUE_DATA': + case 'INITIAL_QUEUE_DATA': { + const newQueue = action.payload.queue; + let newCurrentClimbQueueItem = action.payload.currentClimbQueueItem ?? state.currentClimbQueueItem; + + // Validate that current climb is in the queue + // If not, clear it to prevent inconsistent state + if (newCurrentClimbQueueItem && !newQueue.some(item => item.uuid === newCurrentClimbQueueItem?.uuid)) { + newCurrentClimbQueueItem = null; + } + return { ...state, - queue: action.payload.queue, - currentClimbQueueItem: action.payload.currentClimbQueueItem ?? state.currentClimbQueueItem, + queue: newQueue, + currentClimbQueueItem: newCurrentClimbQueueItem, initialQueueDataReceivedFromPeers: true, }; + } + + case 'UPDATE_QUEUE': { + const newQueue = action.payload.queue; + let newCurrentClimbQueueItem = action.payload.currentClimbQueueItem ?? state.currentClimbQueueItem; + + // Validate that current climb is in the queue + // If not, clear it to prevent inconsistent state + if (newCurrentClimbQueueItem && !newQueue.some(item => item.uuid === newCurrentClimbQueueItem?.uuid)) { + newCurrentClimbQueueItem = null; + } - case 'UPDATE_QUEUE': return { ...state, - queue: action.payload.queue, - currentClimbQueueItem: action.payload.currentClimbQueueItem ?? state.currentClimbQueueItem, + queue: newQueue, + currentClimbQueueItem: newCurrentClimbQueueItem, }; + } case 'ADD_TO_QUEUE': return {