diff --git a/packages/web/app/components/graphql-queue/QueueContext.tsx b/packages/web/app/components/graphql-queue/QueueContext.tsx index daab6ac5..ef292780 100644 --- a/packages/web/app/components/graphql-queue/QueueContext.tsx +++ b/packages/web/app/components/graphql-queue/QueueContext.tsx @@ -654,7 +654,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas // Actions addToQueue: (climb: Climb) => { - const newItem = createClimbQueueItem(climb, clientId, currentUserInfo); + const newItem = createClimbQueueItem({ ...climb, angle: parsedParams.angle }, clientId, currentUserInfo); // Optimistic update dispatch({ type: 'DELTA_ADD_QUEUE_ITEM', payload: { item: newItem } }); @@ -680,7 +680,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas }, setCurrentClimb: async (climb: Climb) => { - const newItem = createClimbQueueItem(climb, clientId, currentUserInfo); + const newItem = createClimbQueueItem({ ...climb, angle: parsedParams.angle }, clientId, currentUserInfo); // Optimistic update dispatch({ type: 'SET_CURRENT_CLIMB', payload: newItem }); 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 index 61d06469..7e35183e 100644 --- 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 @@ -413,6 +413,72 @@ describe('queue-bridge-context', () => { // New item becomes current expect(newCurrent.climb.uuid).toBe('c2'); }); + + // --------------------------------------------------------------- + // Angle override behavior + // --------------------------------------------------------------- + describe('angle override', () => { + it('addToQueue overrides climb angle with current queue angle', () => { + // Current climb is at angle 40, new climb has angle 25 + const currentClimb = createTestClimb({ uuid: 'c1', angle: 40 }); + const item = createTestQueueItem(currentClimb, 'u1'); + const climbWithDifferentAngle = createTestClimb({ uuid: 'c-new', angle: 25 }); + + const { result } = renderWithLocalQueue([item], item); + act(() => { + result.current!.addToQueue(climbWithDifferentAngle); + }); + + const [newQueue] = mockSetLocalQueueState.mock.calls[0]; + // The added climb should have the queue's angle (40), not its own (25) + expect(newQueue[1].climb.angle).toBe(40); + expect(newQueue[1].climb.uuid).toBe('c-new'); + }); + + it('setCurrentClimb overrides climb angle with current queue angle', () => { + // Current climb is at angle 40, proposal climb has angle 0 + const currentClimb = createTestClimb({ uuid: 'c1', angle: 40 }); + const item = createTestQueueItem(currentClimb, 'u1'); + const proposalClimb = createTestClimb({ uuid: 'c-proposal', angle: 0 }); + + const { result } = renderWithLocalQueue([item], item); + act(() => { + result.current!.setCurrentClimb(proposalClimb); + }); + + const [, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + // The proposal climb should have the queue's angle (40), not 0 + expect(newCurrent.climb.angle).toBe(40); + expect(newCurrent.climb.uuid).toBe('c-proposal'); + }); + + it('preserves climb original angle when local queue is empty (no angle source)', () => { + // Empty queue — no current climb to derive angle from + const climbWithAngle = createTestClimb({ uuid: 'c-new', angle: 35 }); + + const { result } = renderWithLocalQueue([], null); + act(() => { + result.current!.addToQueue(climbWithAngle); + }); + + const [newQueue] = mockSetLocalQueueState.mock.calls[0]; + // With no existing angle source, the climb keeps its original angle + expect(newQueue[0].climb.angle).toBe(35); + }); + + it('preserves climb original angle when setCurrentClimb on empty queue', () => { + const proposalClimb = createTestClimb({ uuid: 'c-proposal', angle: 25 }); + + const { result } = renderWithLocalQueue([], null); + act(() => { + result.current!.setCurrentClimb(proposalClimb); + }); + + const [, newCurrent] = mockSetLocalQueueState.mock.calls[0]; + // With no existing angle source, the climb keeps its original angle + expect(newCurrent.climb.angle).toBe(25); + }); + }); }); }); diff --git a/packages/web/app/components/queue-control/queue-bridge-context.tsx b/packages/web/app/components/queue-control/queue-bridge-context.tsx index b300bc23..a83f85f0 100644 --- a/packages/web/app/components/queue-control/queue-bridge-context.tsx +++ b/packages/web/app/components/queue-control/queue-bridge-context.tsx @@ -64,6 +64,9 @@ function usePersistentSessionQueueAdapter(): { const angle: Angle = isParty ? ps.activeSession!.parsedParams.angle : (ps.localCurrentClimbQueueItem?.climb?.angle ?? 0); + // Whether the angle comes from a real source (party session or existing queue item) + // vs the fallback 0 when the local queue is empty. + const hasAngleSource = isParty || !!ps.localCurrentClimbQueueItem; const baseBoardPath = useMemo(() => { if (isParty && ps.activeSession?.boardPath) { @@ -114,7 +117,7 @@ function usePersistentSessionQueueAdapter(): { (climb: Climb) => { if (!boardDetails) return; const newItem: ClimbQueueItem = { - climb, + climb: hasAngleSource ? { ...climb, angle } : climb, addedBy: null, uuid: uuidv4(), suggested: false, @@ -123,7 +126,7 @@ function usePersistentSessionQueueAdapter(): { const current = currentClimbQueueItem ?? newItem; ps.setLocalQueueState(newQueue, current, baseBoardPath, boardDetails); }, - [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps], + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps, angle, hasAngleSource], ); const removeFromQueue = useCallback( @@ -166,7 +169,7 @@ function usePersistentSessionQueueAdapter(): { (climb: Climb) => { if (!boardDetails) return; const newItem: ClimbQueueItem = { - climb, + climb: hasAngleSource ? { ...climb, angle } : climb, addedBy: null, uuid: uuidv4(), suggested: false, @@ -183,7 +186,7 @@ function usePersistentSessionQueueAdapter(): { } ps.setLocalQueueState(newQueue, newItem, baseBoardPath, boardDetails); }, - [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps], + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps, angle, hasAngleSource], ); // No-op functions for fields not used by the bottom bar — each matches its exact type signature diff --git a/packages/web/app/lib/__tests__/queue-storage-db.test.ts b/packages/web/app/lib/__tests__/queue-storage-db.test.ts index 4aa2510c..bd377e9a 100644 --- a/packages/web/app/lib/__tests__/queue-storage-db.test.ts +++ b/packages/web/app/lib/__tests__/queue-storage-db.test.ts @@ -83,25 +83,35 @@ describe('getMostRecentQueue', () => { it('returns the queue with the highest updatedAt', async () => { const boardDetails = createTestBoardDetails(); - await saveQueueState({ - boardPath: '/kilter/1/10/1,2/40', - queue: [createTestClimbQueueItem('a')], - currentClimbQueueItem: null, - boardDetails, - updatedAt: 1000, - }); - await saveQueueState({ - boardPath: '/tension/1/10/1,2/45', - queue: [createTestClimbQueueItem('b')], - currentClimbQueueItem: null, - boardDetails: createTestBoardDetails({ board_name: 'tension' }), - updatedAt: 2000, - }); + // Write directly to IndexedDB with controlled timestamps to avoid + // Date.now() non-determinism in saveQueueState (which overwrites updatedAt). + const db = await openDB(DB_NAME, 1); + await db.put( + STORE_NAME, + { + boardPath: '/kilter/1/10/1,2/40', + queue: [createTestClimbQueueItem('a')], + currentClimbQueueItem: null, + boardDetails, + updatedAt: 1000, + }, + 'queue:/kilter/1/10/1,2/40', + ); + await db.put( + STORE_NAME, + { + boardPath: '/tension/1/10/1,2/45', + queue: [createTestClimbQueueItem('b')], + currentClimbQueueItem: null, + boardDetails: createTestBoardDetails({ board_name: 'tension' }), + updatedAt: 2000, + }, + 'queue:/tension/1/10/1,2/45', + ); + db.close(); const result = await getMostRecentQueue(); expect(result).not.toBeNull(); - // saveQueueState overwrites updatedAt with Date.now(), but the second call - // has a later Date.now() so it will have the higher timestamp expect(result!.boardPath).toBe('/tension/1/10/1,2/45'); expect(result!.queue).toHaveLength(1); expect(result!.queue[0].uuid).toBe('b');