diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.module.css b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.module.css deleted file mode 100644 index 8d1456bdf..000000000 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.module.css +++ /dev/null @@ -1,30 +0,0 @@ -.bottomBarWrapper { - position: fixed; - bottom: 0; - left: 2dvw; - right: 2dvw; - z-index: 10; - background: none; - border-radius: 16px; -} - -@media (min-width: 768px) { - .bottomBarWrapper { - left: 0; - right: 0; - bottom: 0; - padding-bottom: 16px; - border-radius: 0; - } -} - -/* Mobile Safari renders a grey rectangle under the chrome when bottom: 0, - so offset with 2dvh. -webkit-touch-callout is only supported by iOS Safari - (all iOS browsers use WebKit), and the media query limits to touch devices. */ -@supports (-webkit-touch-callout: none) { - @media (hover: none) and (pointer: coarse) { - .bottomBarWrapper { - bottom: 2dvh; - } - } -} diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx index b2e54e0a0..f1cf289fa 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx @@ -4,7 +4,6 @@ import { ParsedBoardRouteParameters, BoardRouteParameters, BoardDetails } from ' import { parseBoardRouteParams, constructClimbListWithSlugs } from '@/app/lib/url-utils'; import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; import { permanentRedirect } from 'next/navigation'; -import QueueControlBar from '@/app/components/queue-control/queue-control-bar'; import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; import { getMoonBoardDetails } from '@/app/lib/moonboard-config'; import BoardSeshHeader from '@/app/components/board-page/header'; @@ -14,14 +13,12 @@ import { PartyProvider } from '@/app/components/party-manager/party-context'; import { BoardSessionBridge } from '@/app/components/persistent-session'; import { Metadata } from 'next'; import BoardPageSkeleton from '@/app/components/board-page/board-page-skeleton'; -import BottomTabBar from '@/app/components/bottom-tab-bar/bottom-tab-bar'; import { BluetoothProvider } from '@/app/components/board-bluetooth-control/bluetooth-context'; import { UISearchParamsProvider } from '@/app/components/queue-control/ui-searchparams-provider'; +import { QueueBridgeInjector } from '@/app/components/queue-control/queue-bridge-context'; import LastUsedBoardTracker from '@/app/components/board-page/last-used-board-tracker'; import { themeTokens } from '@/app/theme/theme-config'; import { getAllBoardConfigs } from '@/app/lib/server-board-configs'; -import { BoardRouteBottomBarRegistrar } from '@/app/components/bottom-tab-bar/board-route-bottom-bar-context'; -import layoutStyles from './layout.module.css'; // Helper to get board details for any board type function getBoardDetailsUniversal(parsedParams: ParsedBoardRouteParameters): BoardDetails { @@ -167,7 +164,6 @@ export default async function BoardLayout(props: PropsWithChildren - +
- -
- - -
diff --git a/packages/web/app/b/[board_slug]/[angle]/layout.module.css b/packages/web/app/b/[board_slug]/[angle]/layout.module.css deleted file mode 100644 index a4105c8fc..000000000 --- a/packages/web/app/b/[board_slug]/[angle]/layout.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.bottomBarWrapper { - position: fixed; - bottom: 2dvh; - left: 2dvw; - right: 2dvw; - z-index: 10; - background: none; - border-radius: 16px; -} diff --git a/packages/web/app/b/[board_slug]/[angle]/layout.tsx b/packages/web/app/b/[board_slug]/[angle]/layout.tsx index 766b077a3..c417bf147 100644 --- a/packages/web/app/b/[board_slug]/[angle]/layout.tsx +++ b/packages/web/app/b/[board_slug]/[angle]/layout.tsx @@ -6,22 +6,19 @@ import { resolveBoardBySlug, boardToRouteParams } from '@/app/lib/board-slug-uti import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; import { getMoonBoardDetails } from '@/app/lib/moonboard-config'; import { ParsedBoardRouteParameters, BoardDetails } from '@/app/lib/types'; -import QueueControlBar from '@/app/components/queue-control/queue-control-bar'; import BoardSeshHeader from '@/app/components/board-page/header'; import { GraphQLQueueProvider } from '@/app/components/graphql-queue'; import { ConnectionSettingsProvider } from '@/app/components/connection-manager/connection-settings-context'; import { PartyProvider } from '@/app/components/party-manager/party-context'; import { BoardSessionBridge } from '@/app/components/persistent-session'; import BoardPageSkeleton from '@/app/components/board-page/board-page-skeleton'; -import BottomTabBar from '@/app/components/bottom-tab-bar/bottom-tab-bar'; import { BluetoothProvider } from '@/app/components/board-bluetooth-control/bluetooth-context'; import { UISearchParamsProvider } from '@/app/components/queue-control/ui-searchparams-provider'; import { BoardProvider } from '@/app/components/board-provider/board-provider-context'; +import { QueueBridgeInjector } from '@/app/components/queue-control/queue-bridge-context'; import LastUsedBoardTracker from '@/app/components/board-page/last-used-board-tracker'; import { getAllBoardConfigs } from '@/app/lib/server-board-configs'; import { constructBoardSlugListUrl } from '@/app/lib/url-utils'; -import { BoardRouteBottomBarRegistrar } from '@/app/components/bottom-tab-bar/board-route-bottom-bar-context'; -import layoutStyles from './layout.module.css'; import { themeTokens } from '@/app/theme/theme-config'; interface BoardSlugRouteParams { @@ -77,7 +74,6 @@ export default async function BoardSlugLayout(props: PropsWithChildren<{ params: return (
- +
- -
- - -
diff --git a/packages/web/app/components/bottom-tab-bar/board-route-bottom-bar-context.tsx b/packages/web/app/components/bottom-tab-bar/board-route-bottom-bar-context.tsx deleted file mode 100644 index 5cc403177..000000000 --- a/packages/web/app/components/bottom-tab-bar/board-route-bottom-bar-context.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; - -interface BoardRouteBottomBarContextType { - hasBoardRouteBottomBar: boolean; - register: () => void; - unregister: () => void; -} - -const BoardRouteBottomBarContext = createContext({ - hasBoardRouteBottomBar: false, - register: () => {}, - unregister: () => {}, -}); - -export function BoardRouteBottomBarProvider({ children }: { children: React.ReactNode }) { - const [count, setCount] = useState(0); - - const register = useCallback(() => { - setCount((c) => c + 1); - }, []); - - const unregister = useCallback(() => { - setCount((c) => Math.max(0, c - 1)); - }, []); - - return ( - 0, register, unregister }}> - {children} - - ); -} - -export function useBoardRouteBottomBar() { - return useContext(BoardRouteBottomBarContext); -} - -/** - * Renders null. On mount, registers with the BoardRouteBottomBarProvider - * to signal that a board route has its own bottom bar. - * On unmount, unregisters so the root bottom bar reappears. - */ -export function BoardRouteBottomBarRegistrar() { - const { register, unregister } = useBoardRouteBottomBar(); - - useEffect(() => { - register(); - return () => unregister(); - }, [register, unregister]); - - return null; -} diff --git a/packages/web/app/components/bottom-tab-bar/bottom-bar-wrapper.module.css b/packages/web/app/components/bottom-tab-bar/bottom-bar-wrapper.module.css index 16e35d037..9b2bc0098 100644 --- a/packages/web/app/components/bottom-tab-bar/bottom-bar-wrapper.module.css +++ b/packages/web/app/components/bottom-tab-bar/bottom-bar-wrapper.module.css @@ -1,14 +1,19 @@ .bottomBarWrapper { position: fixed; bottom: 0; - left: 0; - right: 0; + left: 2dvw; + right: 2dvw; z-index: 10; + background: none; + border-radius: 16px; } @media (min-width: 768px) { .bottomBarWrapper { + left: 0; + right: 0; padding-bottom: 16px; + border-radius: 0; } } diff --git a/packages/web/app/components/graphql-queue/QueueContext.tsx b/packages/web/app/components/graphql-queue/QueueContext.tsx index 833842af4..a73d40064 100644 --- a/packages/web/app/components/graphql-queue/QueueContext.tsx +++ b/packages/web/app/components/graphql-queue/QueueContext.tsx @@ -85,7 +85,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas // On mount, the reducer starts with empty state. The sync effect must not // write to persistent session until restoration (from memory or IndexedDB) // has finished — otherwise it overwrites valid data with empty state, - // causing PersistentQueueControlBar to see an empty queue and unmount. + // causing the queue bridge to see an empty queue and hide the control bar. // Uses useState (not useRef) so the sync effect only sees hasRestored=true // in the render where state.queue already contains the restored data. // With a ref, the restore and sync effects run in the same render cycle: diff --git a/packages/web/app/components/providers/persistent-session-wrapper.tsx b/packages/web/app/components/providers/persistent-session-wrapper.tsx index 3e7d1750b..3b10ef716 100644 --- a/packages/web/app/components/providers/persistent-session-wrapper.tsx +++ b/packages/web/app/components/providers/persistent-session-wrapper.tsx @@ -1,11 +1,18 @@ 'use client'; -import React from 'react'; +import React, { useMemo } from 'react'; import { PartyProfileProvider } from '../party-manager/party-profile-context'; import { PersistentSessionProvider } from '../persistent-session'; -import PersistentQueueControlBar from '../queue-control/persistent-queue-control-bar'; +import { QueueBridgeProvider, useQueueBridgeBoardInfo } from '../queue-control/queue-bridge-context'; +import { useQueueContext } from '../graphql-queue'; +import QueueControlBar from '../queue-control/queue-control-bar'; import BottomTabBar from '../bottom-tab-bar/bottom-tab-bar'; -import { BoardRouteBottomBarProvider, useBoardRouteBottomBar } from '../bottom-tab-bar/board-route-bottom-bar-context'; +import { BoardProvider } from '../board-provider/board-provider-context'; +import { ConnectionSettingsProvider } from '../connection-manager/connection-settings-context'; +import { BluetoothProvider } from '../board-bluetooth-control/bluetooth-context'; +import { FavoritesProvider } from '../climb-actions/favorites-batch-context'; +import { PlaylistsProvider } from '../climb-actions/playlists-batch-context'; +import { useClimbActionsData } from '@/app/hooks/use-climb-actions-data'; import ErrorBoundary from '../error-boundary'; import bottomBarStyles from '../bottom-tab-bar/bottom-bar-wrapper.module.css'; import { BoardConfigData } from '@/app/lib/server-board-configs'; @@ -19,17 +26,17 @@ interface PersistentSessionWrapperProps { * Root-level wrapper that provides: * 1. PartyProfileProvider - user profile from IndexedDB and NextAuth session * 2. PersistentSessionProvider - WebSocket connection management that persists across navigation - * 3. BoardRouteBottomBarProvider - tracks whether a board route has its own bottom bar - * 4. RootBottomBar - persistent queue control bar + bottom tab bar on all non-board pages + * 3. QueueBridgeProvider - bridges queue context from board routes to the persistent bottom bar + * 4. RootBottomBar - always-rendered queue control bar + bottom tab bar */ export default function PersistentSessionWrapper({ children, boardConfigs }: PersistentSessionWrapperProps) { return ( - + {children} - + ); @@ -37,21 +44,64 @@ export default function PersistentSessionWrapper({ children, boardConfigs }: Per /** * Persistent bottom bar rendered at the root level. - * Hides itself when a board route registers its own bottom bar. + * Always renders — the QueueBridge provides queue context from whichever provider is active. + * QueueControlBar is only shown when there is an active queue (board details available). */ function RootBottomBar({ boardConfigs }: { boardConfigs: BoardConfigData }) { - const { hasBoardRouteBottomBar } = useBoardRouteBottomBar(); - - if (hasBoardRouteBottomBar) { - return null; - } + const { boardDetails, angle, hasActiveQueue } = useQueueBridgeBoardInfo(); return (
- - - + {hasActiveQueue && boardDetails && ( + + + + + + + + + + )}
); } + +/** + * Wraps QueueControlBar with FavoritesProvider and PlaylistsProvider. + * Must be rendered inside QueueContext.Provider (via QueueBridge) so useQueueContext works. + * React Query deduplicates API calls with the board route's providers. + */ +function RootQueueControlBarWithProviders({ + boardDetails, + angle, +}: { + boardDetails: NonNullable['boardDetails']>; + angle: number; +}) { + const { queue, currentClimb } = useQueueContext(); + + const climbUuids = useMemo(() => { + const queueUuids = queue.map((item) => item.climb?.uuid).filter(Boolean) as string[]; + if (currentClimb?.uuid) { + queueUuids.push(currentClimb.uuid); + } + return Array.from(new Set(queueUuids)).sort(); + }, [queue, currentClimb]); + + const { favoritesProviderProps, playlistsProviderProps } = useClimbActionsData({ + boardName: boardDetails.board_name, + layoutId: boardDetails.layout_id, + angle, + climbUuids, + }); + + return ( + + + + + + ); +} diff --git a/packages/web/app/components/queue-control/__tests__/queue-bridge-context.test.tsx b/packages/web/app/components/queue-control/__tests__/queue-bridge-context.test.tsx new file mode 100644 index 000000000..82c2a06f2 --- /dev/null +++ b/packages/web/app/components/queue-control/__tests__/queue-bridge-context.test.tsx @@ -0,0 +1,557 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import React from 'react'; + +// --------------------------------------------------------------------------- +// Mocks — must be defined before importing the SUT +// --------------------------------------------------------------------------- + +let mockUuidCounter = 0; +vi.mock('uuid', () => ({ + v4: vi.fn(() => `test-uuid-${++mockUuidCounter}`), +})); + +const mockSetLocalQueueState = vi.fn(); +const mockDeactivateSession = vi.fn(); +const mockClearLocalQueue = vi.fn(); +const mockLoadStoredQueue = vi.fn().mockResolvedValue(null); + +let mockPersistentSession: Record = {}; + +vi.mock('../../persistent-session', () => ({ + usePersistentSession: () => mockPersistentSession, +})); + +// Mock QueueContext used by QueueBridgeInjector to read the board route's context +let mockQueueContextValue: unknown = undefined; +vi.mock('../../graphql-queue/QueueContext', () => { + const React = require('react'); + const ctx = React.createContext(undefined); + return { + QueueContext: ctx, + __esModule: true, + }; +}); + +vi.mock('@/app/lib/url-utils', () => ({ + getBaseBoardPath: (p: string) => p.replace(/\/\d+$/, ''), + DEFAULT_SEARCH_PARAMS: { + gradeAccuracy: 0, + maxGrade: 0, + minGrade: 0, + minRating: 0, + minAscents: 0, + sortBy: 'ascents', + sortOrder: 'desc', + name: '', + onlyClassics: false, + onlyTallClimbs: false, + }, +})); + +// Now import the SUT — after all vi.mock calls +import { + QueueBridgeProvider, + QueueBridgeInjector, + useQueueBridgeBoardInfo, +} from '../queue-bridge-context'; +import { QueueContext } from '../../graphql-queue/QueueContext'; +import type { GraphQLQueueContextType } from '../../graphql-queue/QueueContext'; +import type { BoardDetails, Climb, Angle } from '@/app/lib/types'; +import type { ClimbQueueItem } from '../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createTestBoardDetails(overrides?: Partial): BoardDetails { + return { + board_name: 'kilter', + layout_id: 1, + size_id: 10, + set_ids: '1,2', + images_to_holds: {}, + holdsData: {}, + edge_left: 0, + edge_right: 100, + edge_bottom: 0, + edge_top: 100, + boardHeight: 100, + boardWidth: 100, + layout_name: 'Original', + size_name: '12x12', + size_description: 'Full Size', + set_names: ['Standard', 'Extended'], + ...overrides, + } as BoardDetails; +} + +function createTestClimb(overrides?: Partial): Climb { + return { + uuid: 'climb-1', + setter_username: 'setter1', + name: 'Test Climb', + description: 'A test climb', + frames: 'p1r12p2r13', + angle: 40, + ascensionist_count: 5, + difficulty: '7', + quality_average: '3.5', + stars: 3, + difficulty_error: '', + litUpHoldsMap: {}, + mirrored: false, + benchmark_difficulty: null, + userAscents: 0, + userAttempts: 0, + ...overrides, + } as Climb; +} + +function createTestQueueItem(climb?: Climb, uuid?: string): ClimbQueueItem { + return { + climb: climb ?? createTestClimb(), + addedBy: null, + uuid: uuid ?? 'item-uuid-1', + suggested: false, + }; +} + +function createDefaultPersistentSession(overrides?: Record) { + return { + activeSession: null, + session: null, + isConnecting: false, + hasConnected: false, + error: null, + clientId: null, + isLeader: false, + users: [], + currentClimbQueueItem: null, + queue: [], + localQueue: [], + localCurrentClimbQueueItem: null, + localBoardPath: null, + localBoardDetails: null, + isLocalQueueLoaded: false, + setLocalQueueState: mockSetLocalQueueState, + clearLocalQueue: mockClearLocalQueue, + loadStoredQueue: mockLoadStoredQueue, + deactivateSession: mockDeactivateSession, + activateSession: vi.fn(), + setInitialQueueForSession: vi.fn(), + subscribeToQueueEvents: vi.fn(() => vi.fn()), + addQueueItem: vi.fn(), + removeQueueItem: vi.fn(), + setCurrentClimb: vi.fn(), + setQueue: vi.fn(), + mirrorCurrentClimb: vi.fn(), + triggerResync: vi.fn(), + ...overrides, + }; +} + +/** Minimal fake GraphQLQueueContextType for injection tests */ +function createFakeQueueContext(overrides?: Partial): GraphQLQueueContextType { + return { + queue: [], + currentClimbQueueItem: null, + currentClimb: null, + climbSearchParams: { + gradeAccuracy: 0, + maxGrade: 0, + minGrade: 0, + minRating: 0, + minAscents: 0, + sortBy: 'ascents', + sortOrder: 'desc', + name: '', + onlyClassics: false, + onlyTallClimbs: false, + }, + climbSearchResults: null, + suggestedClimbs: [], + totalSearchResultCount: null, + hasMoreResults: false, + isFetchingClimbs: false, + isFetchingNextPage: false, + hasDoneFirstFetch: false, + viewOnlyMode: false, + parsedParams: { board_name: 'kilter', layout_id: 1, size_id: 10, set_ids: [1, 2], angle: 40 }, + isSessionActive: false, + sessionId: null, + startSession: vi.fn(async () => ''), + joinSession: vi.fn(async () => {}), + endSession: vi.fn(), + sessionSummary: null, + dismissSessionSummary: vi.fn(), + sessionGoal: null, + users: [], + clientId: null, + isLeader: false, + isBackendMode: false, + hasConnected: false, + connectionError: null, + disconnect: vi.fn(), + addToQueue: vi.fn(), + removeFromQueue: vi.fn(), + setCurrentClimb: vi.fn(), + setCurrentClimbQueueItem: vi.fn(), + setClimbSearchParams: vi.fn(), + mirrorClimb: vi.fn(), + fetchMoreClimbs: vi.fn(), + getNextClimbQueueItem: vi.fn(() => null), + getPreviousClimbQueueItem: vi.fn(() => null), + setQueue: vi.fn(), + ...overrides, + } as GraphQLQueueContextType; +} + +/** + * Hook to read the QueueContext value exposed by QueueBridgeProvider. + */ +function useTestQueueContext() { + return React.useContext(QueueContext); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('queue-bridge-context', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUuidCounter = 0; + mockQueueContextValue = undefined; + mockPersistentSession = createDefaultPersistentSession(); + }); + + // ----------------------------------------------------------------------- + // QueueBridgeProvider — adapter mode (no injector mounted) + // ----------------------------------------------------------------------- + describe('QueueBridgeProvider (adapter mode)', () => { + function renderBridgeHook() { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook( + () => ({ + boardInfo: useQueueBridgeBoardInfo(), + queueCtx: useTestQueueContext(), + }), + { wrapper }, + ); + } + + it('provides adapter context when no injector is mounted', () => { + const { result } = renderBridgeHook(); + // The queue context should be defined (adapter fallback) + expect(result.current.queueCtx).toBeDefined(); + expect(result.current.queueCtx!.queue).toEqual([]); + }); + + it('hasActiveQueue is false when no board details and empty queue', () => { + mockPersistentSession = createDefaultPersistentSession(); + const { result } = renderBridgeHook(); + expect(result.current.boardInfo.hasActiveQueue).toBe(false); + }); + + it('hasActiveQueue is true when local queue has items and board details exist', () => { + const bd = createTestBoardDetails(); + const item = createTestQueueItem(); + mockPersistentSession = createDefaultPersistentSession({ + localQueue: [item], + localCurrentClimbQueueItem: item, + localBoardDetails: bd, + localBoardPath: '/kilter/1/10/1,2', + isLocalQueueLoaded: true, + }); + const { result } = renderBridgeHook(); + expect(result.current.boardInfo.hasActiveQueue).toBe(true); + }); + + // ------------------------------------------------------------------- + // Adapter queue operations + // ------------------------------------------------------------------- + describe('adapter queue operations', () => { + const bd = createTestBoardDetails(); + const climb1 = createTestClimb({ uuid: 'c1', name: 'Climb 1' }); + const climb2 = createTestClimb({ uuid: 'c2', name: 'Climb 2' }); + + function renderWithLocalQueue(queue: ClimbQueueItem[], current: ClimbQueueItem | null) { + mockPersistentSession = createDefaultPersistentSession({ + localQueue: queue, + localCurrentClimbQueueItem: current, + localBoardDetails: bd, + localBoardPath: '/kilter/1/10/1,2', + isLocalQueueLoaded: true, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook(() => useTestQueueContext(), { wrapper }); + } + + it('addToQueue creates item and calls setLocalQueueState', () => { + const { result } = renderWithLocalQueue([], null); + act(() => { + result.current!.addToQueue(climb1); + }); + expect(mockSetLocalQueueState).toHaveBeenCalled(); + const [newQueue, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + expect(newQueue).toHaveLength(1); + expect(newQueue[0].climb.uuid).toBe('c1'); + // When current is null, new item becomes current + expect(newCurrent.climb.uuid).toBe('c1'); + }); + + it('removeFromQueue filters item and updates state', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const item2 = createTestQueueItem(climb2, 'u2'); + const { result } = renderWithLocalQueue([item1, item2], item1); + act(() => { + result.current!.removeFromQueue(item1); + }); + expect(mockSetLocalQueueState).toHaveBeenCalled(); + const [newQueue, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + expect(newQueue).toHaveLength(1); + expect(newQueue[0].uuid).toBe('u2'); + // Current was removed, so falls back to first item + expect(newCurrent.uuid).toBe('u2'); + }); + + it('setCurrentClimbQueueItem updates current and calls setLocalQueueState', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const item2 = createTestQueueItem(climb2, 'u2'); + const { result } = renderWithLocalQueue([item1, item2], item1); + act(() => { + result.current!.setCurrentClimbQueueItem(item2); + }); + expect(mockSetLocalQueueState).toHaveBeenCalled(); + const [, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + expect(newCurrent.uuid).toBe('u2'); + }); + + it('getNextClimbQueueItem returns next item in queue', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const item2 = createTestQueueItem(climb2, 'u2'); + const { result } = renderWithLocalQueue([item1, item2], item1); + const next = result.current!.getNextClimbQueueItem(); + expect(next?.uuid).toBe('u2'); + }); + + it('getPreviousClimbQueueItem returns previous item in queue', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const item2 = createTestQueueItem(climb2, 'u2'); + const { result } = renderWithLocalQueue([item1, item2], item2); + const prev = result.current!.getPreviousClimbQueueItem(); + expect(prev?.uuid).toBe('u1'); + }); + + it('getNextClimbQueueItem returns null when at end', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const { result } = renderWithLocalQueue([item1], item1); + const next = result.current!.getNextClimbQueueItem(); + expect(next).toBeNull(); + }); + + it('getPreviousClimbQueueItem returns null when at beginning', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const { result } = renderWithLocalQueue([item1], item1); + const prev = result.current!.getPreviousClimbQueueItem(); + expect(prev).toBeNull(); + }); + + it('mirrorClimb toggles mirrored flag', () => { + const climb = createTestClimb({ uuid: 'c1', mirrored: false }); + const item = createTestQueueItem(climb, 'u1'); + const { result } = renderWithLocalQueue([item], item); + act(() => { + result.current!.mirrorClimb(); + }); + expect(mockSetLocalQueueState).toHaveBeenCalled(); + const [newQueue, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + expect(newCurrent.climb.mirrored).toBe(true); + expect(newQueue[0].climb.mirrored).toBe(true); + }); + + it('setQueue replaces queue and preserves current if present', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const item2 = createTestQueueItem(climb2, 'u2'); + const { result } = renderWithLocalQueue([item1], item1); + act(() => { + result.current!.setQueue([item1, item2]); + }); + expect(mockSetLocalQueueState).toHaveBeenCalled(); + const [newQueue, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + expect(newQueue).toHaveLength(2); + // Current was in the new queue so it's preserved + expect(newCurrent.uuid).toBe('u1'); + }); + + it('setQueue resets current to first when old current not in new queue', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const item2 = createTestQueueItem(climb2, 'u2'); + const { result } = renderWithLocalQueue([item1], item1); + act(() => { + result.current!.setQueue([item2]); + }); + expect(mockSetLocalQueueState).toHaveBeenCalled(); + const [, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + expect(newCurrent.uuid).toBe('u2'); + }); + + it('setCurrentClimb inserts after current in queue', () => { + const item1 = createTestQueueItem(climb1, 'u1'); + const { result } = renderWithLocalQueue([item1], item1); + act(() => { + result.current!.setCurrentClimb(climb2); + }); + expect(mockSetLocalQueueState).toHaveBeenCalled(); + const [newQueue, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + // New item was inserted after item1 + expect(newQueue).toHaveLength(2); + expect(newQueue[0].uuid).toBe('u1'); + expect(newQueue[1].climb.uuid).toBe('c2'); + // New item becomes current + expect(newCurrent.climb.uuid).toBe('c2'); + }); + }); + }); + + // ----------------------------------------------------------------------- + // QueueBridgeInjector + // ----------------------------------------------------------------------- + describe('QueueBridgeInjector', () => { + const bd = createTestBoardDetails(); + const angle: Angle = 40; + + /** + * Renders the injector inside a QueueBridgeProvider with an inner + * QueueContext.Provider (simulating GraphQLQueueProvider) between + * the bridge and the injector. + * + * The hook reads from the bridge's QueueContext (root level), while + * the injector reads from the inner QueueContext (board route level). + */ + function renderInjector(boardRouteCtx: GraphQLQueueContextType | undefined) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {/* Hook (children) reads bridge's QueueContext = effectiveContext */} + {children} + {/* Inner provider simulates GraphQLQueueProvider on board route */} + + + + + ); + + return renderHook( + () => ({ + boardInfo: useQueueBridgeBoardInfo(), + queueCtx: useTestQueueContext(), + }), + { wrapper }, + ); + } + + it('injects board details and context on mount', () => { + const fakeCtx = createFakeQueueContext({ queue: [createTestQueueItem()] }); + const { result } = renderInjector(fakeCtx); + + // Injected: hasActiveQueue should be true because injector is mounted + expect(result.current.boardInfo.hasActiveQueue).toBe(true); + expect(result.current.boardInfo.boardDetails).toEqual(bd); + expect(result.current.boardInfo.angle).toBe(40); + // The bridge's exposed QueueContext should be the injected one + expect(result.current.queueCtx).toBe(fakeCtx); + }); + + it('clears on unmount', () => { + const fakeCtx = createFakeQueueContext(); + const { result, unmount } = renderInjector(fakeCtx); + + // Before unmount — injected + expect(result.current.boardInfo.hasActiveQueue).toBe(true); + + unmount(); + + // After unmount the provider falls back to adapter — no board details + // Verify by rendering a fresh provider with no injector + const wrapper2 = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result: result2 } = renderHook(() => useQueueBridgeBoardInfo(), { wrapper: wrapper2 }); + expect(result2.current.hasActiveQueue).toBe(false); + }); + + it('updates context when queueContext changes', () => { + const fakeCtx1 = createFakeQueueContext({ queue: [] }); + const fakeCtx2 = createFakeQueueContext({ queue: [createTestQueueItem()] }); + + // Use a mutable variable so we can change the value without remounting + let boardRouteCtx: GraphQLQueueContextType | undefined = fakeCtx1; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + + + + ); + + const { result, rerender } = renderHook( + () => ({ + boardInfo: useQueueBridgeBoardInfo(), + queueCtx: useTestQueueContext(), + }), + { wrapper }, + ); + + expect(result.current.queueCtx).toBe(fakeCtx1); + + // Change the board route context and rerender (same wrapper, so provider state persists) + boardRouteCtx = fakeCtx2; + rerender(); + + // The injector's useEffect should have called updateContext + expect(result.current.queueCtx).toBe(fakeCtx2); + }); + + it('handles initially-null queueContext via deferred injection', () => { + // Use a mutable variable so we can change the value without remounting + let boardRouteCtx: GraphQLQueueContextType | undefined = undefined; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + + + + ); + + const { result, rerender } = renderHook( + () => ({ + boardInfo: useQueueBridgeBoardInfo(), + queueCtx: useTestQueueContext(), + }), + { wrapper }, + ); + + // No injection yet — falls back to adapter (no local queue = not active) + expect(result.current.boardInfo.hasActiveQueue).toBe(false); + + // Now provide a context value (simulating GraphQLQueueProvider becoming ready) + const fakeCtx = createFakeQueueContext({ queue: [createTestQueueItem()] }); + boardRouteCtx = fakeCtx; + rerender(); + + // The useEffect should have fired the deferred injection + expect(result.current.boardInfo.hasActiveQueue).toBe(true); + expect(result.current.boardInfo.boardDetails).toEqual(bd); + expect(result.current.queueCtx).toBe(fakeCtx); + }); + }); +}); diff --git a/packages/web/app/components/queue-control/persistent-queue-control-bar.tsx b/packages/web/app/components/queue-control/persistent-queue-control-bar.tsx deleted file mode 100644 index f940c1ffb..000000000 --- a/packages/web/app/components/queue-control/persistent-queue-control-bar.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import React, { useMemo } from 'react'; -import { usePersistentSession } from '../persistent-session'; -import { GraphQLQueueProvider } from '../graphql-queue'; -import { ConnectionSettingsProvider } from '../connection-manager/connection-settings-context'; -import { BoardProvider } from '../board-provider/board-provider-context'; -import { BluetoothProvider } from '../board-bluetooth-control/bluetooth-context'; -import QueueControlBar from './queue-control-bar'; -import { getBaseBoardPath } from '@/app/lib/url-utils'; -import type { ParsedBoardRouteParameters } from '@/app/lib/types'; - -/** - * Self-contained queue control bar for use outside board routes. - * - * Reads queue state from the persistent session, determines whether there is - * an active queue to display, and wraps QueueControlBar with the full - * GraphQLQueueProvider (supporting search, suggestions, playlists, favourites, - * and party mode) so the queue behaves identically everywhere. - * - * Returns null when there is nothing to show, so callers can render it - * unconditionally and only worry about positioning / layout. - */ -interface PersistentQueueControlBarProps { - className?: string; -} - -export default function PersistentQueueControlBar({ className }: PersistentQueueControlBarProps) { - const { - activeSession, - localQueue, - localCurrentClimbQueueItem, - localBoardDetails, - localBoardPath, - } = usePersistentSession(); - - const isPartyMode = !!activeSession; - const boardDetails = isPartyMode ? activeSession.boardDetails : localBoardDetails; - const angle = isPartyMode - ? activeSession.parsedParams.angle - : (localCurrentClimbQueueItem?.climb?.angle ?? 0); - const hasActiveQueue = - (localQueue.length > 0 || !!localCurrentClimbQueueItem || !!activeSession) && !!boardDetails; - - // Build parsedParams from boardDetails + angle (same fields the board route extracts from the URL) - const parsedParams: ParsedBoardRouteParameters | null = useMemo(() => { - if (!boardDetails) return null; - return { - board_name: boardDetails.board_name, - layout_id: boardDetails.layout_id, - size_id: boardDetails.size_id, - set_ids: boardDetails.set_ids, - angle, - }; - }, [boardDetails, angle]); - - // Compute the base board path that GraphQLQueueProvider uses to identify the queue. - // In party mode use the session's board path; in local mode use the stored board path. - const baseBoardPath = useMemo(() => { - if (isPartyMode && activeSession.boardPath) { - return getBaseBoardPath(activeSession.boardPath); - } - return localBoardPath ?? ''; - }, [isPartyMode, activeSession?.boardPath, localBoardPath]); - - if (!hasActiveQueue || !boardDetails || !parsedParams) { - return null; - } - - const content = ( - - - - - - - - - - ); - - if (className) { - return
{content}
; - } - - return content; -} diff --git a/packages/web/app/components/queue-control/queue-bridge-context.tsx b/packages/web/app/components/queue-control/queue-bridge-context.tsx new file mode 100644 index 000000000..b300bc236 --- /dev/null +++ b/packages/web/app/components/queue-control/queue-bridge-context.tsx @@ -0,0 +1,405 @@ +'use client'; + +import React, { createContext, useContext, useState, useCallback, useMemo, useLayoutEffect, useRef, useEffect } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { QueueContext, type GraphQLQueueContextType } from '../graphql-queue/QueueContext'; +import { usePersistentSession } from '../persistent-session'; +import { getBaseBoardPath } from '@/app/lib/url-utils'; +import { DEFAULT_SEARCH_PARAMS } from '@/app/lib/url-utils'; +import type { BoardDetails, Angle, Climb, SearchRequestPagination } from '@/app/lib/types'; +import type { ClimbQueueItem } from './types'; + +// ------------------------------------------------------------------- +// Board info context (for the root-level bottom bar to know what board is active) +// ------------------------------------------------------------------- + +interface QueueBridgeBoardInfo { + boardDetails: BoardDetails | null; + angle: Angle; + hasActiveQueue: boolean; +} + +const QueueBridgeBoardInfoContext = createContext({ + boardDetails: null, + angle: 0, + hasActiveQueue: false, +}); + +export function useQueueBridgeBoardInfo() { + return useContext(QueueBridgeBoardInfoContext); +} + +// ------------------------------------------------------------------- +// Setter context (for the injector to push board-route context into the bridge) +// ------------------------------------------------------------------- + +interface QueueBridgeSetters { + inject: (ctx: GraphQLQueueContextType, bd: BoardDetails, angle: Angle) => void; + updateContext: (ctx: GraphQLQueueContextType) => void; + clear: () => void; +} + +const QueueBridgeSetterContext = createContext({ + inject: () => {}, + updateContext: () => {}, + clear: () => {}, +}); + +// ------------------------------------------------------------------- +// usePersistentSessionQueueAdapter — thin adapter over PersistentSession +// ------------------------------------------------------------------- + +function usePersistentSessionQueueAdapter(): { + context: GraphQLQueueContextType; + boardDetails: BoardDetails | null; + angle: Angle; + hasActiveQueue: boolean; +} { + const ps = usePersistentSession(); + + const isParty = !!ps.activeSession; + const queue = isParty ? ps.queue : ps.localQueue; + const currentClimbQueueItem = isParty ? ps.currentClimbQueueItem : ps.localCurrentClimbQueueItem; + const boardDetails = isParty ? ps.activeSession!.boardDetails : ps.localBoardDetails; + const angle: Angle = isParty + ? ps.activeSession!.parsedParams.angle + : (ps.localCurrentClimbQueueItem?.climb?.angle ?? 0); + + const baseBoardPath = useMemo(() => { + if (isParty && ps.activeSession?.boardPath) { + return getBaseBoardPath(ps.activeSession.boardPath); + } + return ps.localBoardPath ?? ''; + }, [isParty, ps.activeSession?.boardPath, ps.localBoardPath]); + + const hasActiveQueue = (queue.length > 0 || !!currentClimbQueueItem || isParty) && !!boardDetails; + + const parsedParams = useMemo(() => { + if (!boardDetails) { + // Fallback — should not be consumed when hasActiveQueue is false + return { board_name: 'kilter' as const, layout_id: 0, size_id: 0, set_ids: [0], angle: 0 }; + } + return { + board_name: boardDetails.board_name, + layout_id: boardDetails.layout_id, + size_id: boardDetails.size_id, + set_ids: boardDetails.set_ids, + angle, + }; + }, [boardDetails, angle]); + + const getNextClimbQueueItem = useCallback((): ClimbQueueItem | null => { + const idx = queue.findIndex(({ uuid }) => uuid === currentClimbQueueItem?.uuid); + return idx >= 0 && idx < queue.length - 1 ? queue[idx + 1] : null; + }, [queue, currentClimbQueueItem]); + + const getPreviousClimbQueueItem = useCallback((): ClimbQueueItem | null => { + const idx = queue.findIndex(({ uuid }) => uuid === currentClimbQueueItem?.uuid); + return idx > 0 ? queue[idx - 1] : null; + }, [queue, currentClimbQueueItem]); + + const setCurrentClimbQueueItem = useCallback( + (item: ClimbQueueItem) => { + if (!boardDetails) return; + const alreadyInQueue = queue.some(q => q.uuid === item.uuid); + // Skip if item is already current and already in the queue — nothing changed + if (alreadyInQueue && currentClimbQueueItem?.uuid === item.uuid) return; + const newQueue = alreadyInQueue ? queue : [...queue, item]; + ps.setLocalQueueState(newQueue, item, baseBoardPath, boardDetails); + }, + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps], + ); + + const addToQueue = useCallback( + (climb: Climb) => { + if (!boardDetails) return; + const newItem: ClimbQueueItem = { + climb, + addedBy: null, + uuid: uuidv4(), + suggested: false, + }; + const newQueue = [...queue, newItem]; + const current = currentClimbQueueItem ?? newItem; + ps.setLocalQueueState(newQueue, current, baseBoardPath, boardDetails); + }, + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps], + ); + + const removeFromQueue = useCallback( + (item: ClimbQueueItem) => { + if (!boardDetails) return; + const newQueue = queue.filter(q => q.uuid !== item.uuid); + const newCurrent = currentClimbQueueItem?.uuid === item.uuid + ? (newQueue[0] ?? null) + : currentClimbQueueItem; + ps.setLocalQueueState(newQueue, newCurrent, baseBoardPath, boardDetails); + }, + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps], + ); + + const setQueue = useCallback( + (newQueue: ClimbQueueItem[]) => { + if (!boardDetails) return; + const newCurrent = newQueue.length === 0 + ? null + : (currentClimbQueueItem && newQueue.some(q => q.uuid === currentClimbQueueItem.uuid) + ? currentClimbQueueItem + : newQueue[0]); + ps.setLocalQueueState(newQueue, newCurrent, baseBoardPath, boardDetails); + }, + [currentClimbQueueItem, boardDetails, baseBoardPath, ps], + ); + + const mirrorClimb = useCallback(() => { + if (!currentClimbQueueItem?.climb || !boardDetails) return; + const mirrored = !currentClimbQueueItem.climb.mirrored; + const updatedItem: ClimbQueueItem = { + ...currentClimbQueueItem, + climb: { ...currentClimbQueueItem.climb, mirrored }, + }; + const newQueue = queue.map(q => (q.uuid === updatedItem.uuid ? updatedItem : q)); + ps.setLocalQueueState(newQueue, updatedItem, baseBoardPath, boardDetails); + }, [currentClimbQueueItem, queue, boardDetails, baseBoardPath, ps]); + + const setCurrentClimb = useCallback( + (climb: Climb) => { + if (!boardDetails) return; + const newItem: ClimbQueueItem = { + climb, + addedBy: null, + uuid: uuidv4(), + suggested: false, + }; + // Insert after current in queue + const currentIdx = currentClimbQueueItem + ? queue.findIndex(q => q.uuid === currentClimbQueueItem.uuid) + : -1; + const newQueue = [...queue]; + if (currentIdx >= 0) { + newQueue.splice(currentIdx + 1, 0, newItem); + } else { + newQueue.push(newItem); + } + ps.setLocalQueueState(newQueue, newItem, baseBoardPath, boardDetails); + }, + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps], + ); + + // No-op functions for fields not used by the bottom bar — each matches its exact type signature + const noop = useCallback(() => {}, []); + const noopStartSession = useCallback( + async (_options?: { discoverable?: boolean; name?: string; sessionId?: string }) => '', + [], + ); + const noopJoinSession = useCallback(async (_sessionId: string) => {}, []); + const noopSetClimbSearchParams = useCallback((_params: SearchRequestPagination) => {}, []); + + const context: GraphQLQueueContextType = useMemo( + () => ({ + queue, + currentClimbQueueItem, + currentClimb: currentClimbQueueItem?.climb ?? null, + climbSearchParams: DEFAULT_SEARCH_PARAMS, + climbSearchResults: null, + suggestedClimbs: [], + totalSearchResultCount: null, + hasMoreResults: false, + isFetchingClimbs: false, + isFetchingNextPage: false, + hasDoneFirstFetch: false, + viewOnlyMode: false, + parsedParams, + + // Session management + isSessionActive: isParty && ps.hasConnected, + sessionId: ps.activeSession?.sessionId ?? null, + startSession: noopStartSession, + joinSession: noopJoinSession, + endSession: ps.deactivateSession, + sessionSummary: null, + dismissSessionSummary: noop, + sessionGoal: ps.session?.goal ?? null, + + // Session data + users: isParty ? ps.users : [], + clientId: ps.clientId, + isLeader: ps.isLeader, + isBackendMode: true, + hasConnected: ps.hasConnected, + connectionError: ps.error, + disconnect: ps.deactivateSession, + + // Actions + addToQueue, + removeFromQueue, + setCurrentClimb, + setCurrentClimbQueueItem, + setClimbSearchParams: noopSetClimbSearchParams, + mirrorClimb, + fetchMoreClimbs: noop, + getNextClimbQueueItem, + getPreviousClimbQueueItem, + setQueue, + }), + [ + queue, + currentClimbQueueItem, + parsedParams, + isParty, + ps.hasConnected, + ps.activeSession?.sessionId, + ps.deactivateSession, + ps.session?.goal, + ps.users, + ps.clientId, + ps.isLeader, + ps.error, + noopStartSession, + noopJoinSession, + noopSetClimbSearchParams, + noop, + addToQueue, + removeFromQueue, + setCurrentClimb, + setCurrentClimbQueueItem, + mirrorClimb, + getNextClimbQueueItem, + getPreviousClimbQueueItem, + setQueue, + ], + ); + + return { context, boardDetails, angle, hasActiveQueue }; +} + +// ------------------------------------------------------------------- +// QueueBridgeProvider — wraps children + bottom bar at root level +// ------------------------------------------------------------------- + +export function QueueBridgeProvider({ children }: { children: React.ReactNode }) { + // Whether a board route injector is currently mounted + const [isInjected, setIsInjected] = useState(false); + // Board details and angle from the injector (stable across context updates) + const [injectedBoardDetails, setInjectedBoardDetails] = useState(null); + const [injectedAngle, setInjectedAngle] = useState(0); + // The queue context value is stored in a ref to avoid cleanup/setup cycles + // on every context update. The ref is read synchronously during render. + const injectedContextRef = useRef(null); + // Counter to force re-renders when the injected context ref changes + const [contextVersion, setContextVersion] = useState(0); + + const adapter = usePersistentSessionQueueAdapter(); + + // When a board route is active (isInjected), use the injected context. + // Otherwise, fall back to the PersistentSession adapter. + // + // Why ref + version counter instead of useState? Storing the full context object + // in state would trigger React's cleanup/setup cycle on every context update, + // causing the bottom bar to briefly unmount and remount (visible flash). By + // keeping the value in a ref and bumping a version counter, we get a re-render + // that reads the latest ref value without the cleanup/setup cost. + // eslint-disable-next-line react-hooks/exhaustive-deps -- contextVersion forces re-read of ref + const effectiveContext = useMemo( + () => (isInjected && injectedContextRef.current) ? injectedContextRef.current : adapter.context, + [isInjected, contextVersion, adapter.context], + ); + const effectiveBoardDetails = isInjected ? injectedBoardDetails : adapter.boardDetails; + const effectiveAngle = isInjected ? injectedAngle : adapter.angle; + const effectiveHasActiveQueue = isInjected + ? true // If injected, a board route is active — always show bar + : adapter.hasActiveQueue; + + const boardInfo = useMemo( + () => ({ + boardDetails: effectiveBoardDetails, + angle: effectiveAngle, + hasActiveQueue: effectiveHasActiveQueue, + }), + [effectiveBoardDetails, effectiveAngle, effectiveHasActiveQueue], + ); + + const inject = useCallback((ctx: GraphQLQueueContextType, bd: BoardDetails, a: Angle) => { + injectedContextRef.current = ctx; + setInjectedBoardDetails(bd); + setInjectedAngle(a); + setIsInjected(true); + setContextVersion(v => v + 1); + }, []); + + const updateContext = useCallback((ctx: GraphQLQueueContextType) => { + injectedContextRef.current = ctx; + setContextVersion(v => v + 1); + }, []); + + const clear = useCallback(() => { + injectedContextRef.current = null; + setIsInjected(false); + setInjectedBoardDetails(null); + setInjectedAngle(0); + setContextVersion(v => v + 1); + }, []); + + const setters = useMemo( + () => ({ inject, updateContext, clear }), + [inject, updateContext, clear], + ); + + return ( + + + + {children} + + + + ); +} + +// ------------------------------------------------------------------- +// QueueBridgeInjector — placed inside board route layouts +// ------------------------------------------------------------------- + +interface QueueBridgeInjectorProps { + boardDetails: BoardDetails; + angle: Angle; +} + +export function QueueBridgeInjector({ boardDetails, angle }: QueueBridgeInjectorProps) { + const { inject, updateContext, clear } = useContext(QueueBridgeSetterContext); + + // Read the board route's QueueContext (from GraphQLQueueProvider which is a parent) + const queueContext = useContext(QueueContext); + + // Track whether we've done the initial injection + const hasInjectedRef = useRef(false); + + // Initial injection: set board details + context on mount + useLayoutEffect(() => { + if (queueContext) { + inject(queueContext, boardDetails, angle); + hasInjectedRef.current = true; + } + // Only clean up on unmount (navigating away from board route) + return () => { + hasInjectedRef.current = false; + clear(); + }; + // Only re-run when board details or angle change (navigation between boards) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [boardDetails, angle, inject, clear]); + + // Update the context ref whenever the queue context value changes. + // Also handles deferred injection if queueContext was null during the useLayoutEffect. + useEffect(() => { + if (!queueContext) return; + if (hasInjectedRef.current) { + updateContext(queueContext); + } else { + inject(queueContext, boardDetails, angle); + hasInjectedRef.current = true; + } + }, [queueContext, updateContext, inject, boardDetails, angle]); + + return null; +} diff --git a/packages/web/app/components/queue-control/queue-control-bar.tsx b/packages/web/app/components/queue-control/queue-control-bar.tsx index 6d426b9d5..dce8880ab 100644 --- a/packages/web/app/components/queue-control/queue-control-bar.tsx +++ b/packages/web/app/components/queue-control/queue-control-bar.tsx @@ -114,7 +114,11 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } climb.uuid, climb.name, ) - : `/${params.board_name}/${params.layout_id}/${params.size_id}/${params.set_ids}/${params.angle}/${fallbackPath}/${climb.uuid}`; + : params.board_name + ? `/${params.board_name}/${params.layout_id}/${params.size_id}/${params.set_ids}/${params.angle}/${fallbackPath}/${climb.uuid}` + : null; + + if (!climbUrl) return null; // Preserve search params in play mode if (isPlayPage) { @@ -138,7 +142,8 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } }); if (shouldNavigate) { - router.push(buildClimbUrl(nextClimb.climb)); + const url = buildClimbUrl(nextClimb.climb); + if (url) router.push(url); } }, [nextClimb, viewOnlyMode, setCurrentClimbQueueItem, shouldNavigate, router, buildClimbUrl, boardDetails]); @@ -153,7 +158,8 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } }); if (shouldNavigate) { - router.push(buildClimbUrl(previousClimb.climb)); + const url = buildClimbUrl(previousClimb.climb); + if (url) router.push(url); } }, [previousClimb, viewOnlyMode, setCurrentClimbQueueItem, shouldNavigate, router, buildClimbUrl, boardDetails]); @@ -174,7 +180,7 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } const { layout_name, size_name, size_description, set_names, board_name } = boardDetails; - let baseUrl: string; + let baseUrl: string | null; if (layout_name && size_name && set_names) { baseUrl = constructPlayUrlWithSlugs( board_name, @@ -186,8 +192,10 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } currentClimb.uuid, currentClimb.name, ); - } else { + } else if (params.board_name) { baseUrl = `/${params.board_name}/${params.layout_id}/${params.size_id}/${params.set_ids}/${params.angle}/play/${currentClimb.uuid}`; + } else { + return null; } const queryString = searchParams.toString(); diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx index 8bf3ab470..a5935c6d8 100644 --- a/packages/web/app/layout.tsx +++ b/packages/web/app/layout.tsx @@ -32,13 +32,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo - - + + {children} - - + +