From b262aaefaa6403d35156060d6a2ebe07555a8a9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 10:23:51 +0000 Subject: [PATCH 1/3] fix: use current board angle when setting proposal climb as active When a proposal climb was set as the active climb via "Set Active", the climb's angle would be set to 0 or the proposal's stored angle instead of the current board/queue angle. This happened because the climb object from the proposal card carried the proposal's angle. Override the climb's angle with the current board angle in both addToQueue and setCurrentClimb in the GraphQL QueueContext (party mode) and the persistent session queue adapter (local mode). https://claude.ai/code/session_01Na4cZifibvzbqcjysHmDiE --- .../web/app/components/graphql-queue/QueueContext.tsx | 4 ++-- .../app/components/queue-control/queue-bridge-context.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) 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/queue-bridge-context.tsx b/packages/web/app/components/queue-control/queue-bridge-context.tsx index b300bc23..fdfc37d2 100644 --- a/packages/web/app/components/queue-control/queue-bridge-context.tsx +++ b/packages/web/app/components/queue-control/queue-bridge-context.tsx @@ -114,7 +114,7 @@ function usePersistentSessionQueueAdapter(): { (climb: Climb) => { if (!boardDetails) return; const newItem: ClimbQueueItem = { - climb, + climb: { ...climb, angle }, addedBy: null, uuid: uuidv4(), suggested: false, @@ -123,7 +123,7 @@ function usePersistentSessionQueueAdapter(): { const current = currentClimbQueueItem ?? newItem; ps.setLocalQueueState(newQueue, current, baseBoardPath, boardDetails); }, - [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps], + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps, angle], ); const removeFromQueue = useCallback( @@ -166,7 +166,7 @@ function usePersistentSessionQueueAdapter(): { (climb: Climb) => { if (!boardDetails) return; const newItem: ClimbQueueItem = { - climb, + climb: { ...climb, angle }, addedBy: null, uuid: uuidv4(), suggested: false, @@ -183,7 +183,7 @@ function usePersistentSessionQueueAdapter(): { } ps.setLocalQueueState(newQueue, newItem, baseBoardPath, boardDetails); }, - [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps], + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps, angle], ); // No-op functions for fields not used by the bottom bar — each matches its exact type signature From 90800823ec854820b87009fc55f51234f70fbbf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 11:17:10 +0000 Subject: [PATCH 2/3] fix: preserve climb angle when local queue is empty, add angle override tests When the local queue is empty (no current climb), the angle defaults to 0. Previously this would override any added climb's angle to 0. Now only override the climb's angle when there's a real angle source (party session or existing queue item with an angle). Add 4 tests verifying: - addToQueue overrides angle with current queue angle - setCurrentClimb overrides angle with current queue angle - addToQueue preserves climb's original angle on empty queue - setCurrentClimb preserves climb's original angle on empty queue https://claude.ai/code/session_01Na4cZifibvzbqcjysHmDiE --- .../__tests__/queue-bridge-context.test.tsx | 66 +++++++++++++++++++ .../queue-control/queue-bridge-context.tsx | 11 ++-- 2 files changed, 73 insertions(+), 4 deletions(-) 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 fdfc37d2..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, angle }, + 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, angle], + [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, angle }, + 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, angle], + [queue, currentClimbQueueItem, boardDetails, baseBoardPath, ps, angle, hasAngleSource], ); // No-op functions for fields not used by the bottom bar — each matches its exact type signature From c7b47f82c72941da7dccb3647563f2379c78848d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 11:23:30 +0000 Subject: [PATCH 3/3] fix: make getMostRecentQueue test deterministic The test relied on two sequential saveQueueState calls getting different Date.now() values, but both could execute within the same millisecond, making the result non-deterministic. Write directly to IndexedDB with controlled timestamps instead, matching the pattern used by the corrupted items test. https://claude.ai/code/session_01Na4cZifibvzbqcjysHmDiE --- .../lib/__tests__/queue-storage-db.test.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) 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');