diff --git a/packages/backend/src/__tests__/session-angle.test.ts b/packages/backend/src/__tests__/session-angle.test.ts new file mode 100644 index 00000000..a4612ec1 --- /dev/null +++ b/packages/backend/src/__tests__/session-angle.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for session angle functionality: + * 1. sessionTypeResolver.angle - extracting angle from boardPath + * 2. parseBoardPath helper - parsing boardPath into components + * 3. updateSessionAngle mutation - updating session angle + */ +import { describe, it, expect, vi } from 'vitest'; +import { sessionTypeResolver } from '../graphql/resolvers/sessions/type-resolvers'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; + +/** + * Re-implementation of parseBoardPath for testing purposes. + * This mirrors the logic in mutations.ts to test the parsing behavior. + */ +function parseBoardPath(boardPath: string): { + boardName: string; + layoutId: number; + sizeId: number; + setIds: string; + angle: number; +} | null { + const parts = boardPath.split('/').filter(Boolean); + if (parts.length < 5) return null; + + const boardName = parts[0]; + if (!SUPPORTED_BOARDS.includes(boardName as typeof SUPPORTED_BOARDS[number])) { + return null; + } + + const layoutId = parseInt(parts[1], 10); + const sizeId = parseInt(parts[2], 10); + const angle = parseInt(parts[4], 10); + + if (isNaN(layoutId) || isNaN(sizeId) || isNaN(angle)) { + return null; + } + + return { + boardName, + layoutId, + sizeId, + setIds: parts[3], + angle, + }; +} + +describe('Session Angle', () => { + describe('sessionTypeResolver.angle', () => { + it('should extract angle from valid boardPath', () => { + const session = { boardPath: 'kilter/1/10/1/40' }; + expect(sessionTypeResolver.angle(session)).toBe(40); + }); + + it('should extract angle from boardPath with different values', () => { + expect(sessionTypeResolver.angle({ boardPath: 'tension/2/15/2,3/55' })).toBe(55); + expect(sessionTypeResolver.angle({ boardPath: 'kilter/1/1/1/0' })).toBe(0); + expect(sessionTypeResolver.angle({ boardPath: 'kilter/1/1/1/70' })).toBe(70); + }); + + it('should handle boardPath with leading slash', () => { + const session = { boardPath: '/kilter/1/10/1/45' }; + // filter(Boolean) removes empty strings, so this should still work + expect(sessionTypeResolver.angle(session)).toBe(45); + }); + + it('should return default 40 for malformed boardPath', () => { + expect(sessionTypeResolver.angle({ boardPath: '' })).toBe(40); + expect(sessionTypeResolver.angle({ boardPath: 'kilter' })).toBe(40); + expect(sessionTypeResolver.angle({ boardPath: 'kilter/1/2/3' })).toBe(40); + }); + + it('should return default 40 for non-numeric angle', () => { + const session = { boardPath: 'kilter/1/10/1/abc' }; + expect(sessionTypeResolver.angle(session)).toBe(40); + }); + + it('should handle boardPath with trailing segments', () => { + // The resolver uses index 4 (5th segment), so trailing segments should not affect it + const session = { boardPath: 'kilter/1/10/1/40/list' }; + expect(sessionTypeResolver.angle(session)).toBe(40); + }); + }); + + describe('parseBoardPath helper', () => { + it('should parse valid boardPath', () => { + const result = parseBoardPath('kilter/1/10/1,2/40'); + expect(result).toEqual({ + boardName: 'kilter', + layoutId: 1, + sizeId: 10, + setIds: '1,2', + angle: 40, + }); + }); + + it('should parse boardPath with leading slash', () => { + const result = parseBoardPath('/kilter/1/10/1/45'); + expect(result).toEqual({ + boardName: 'kilter', + layoutId: 1, + sizeId: 10, + setIds: '1', + angle: 45, + }); + }); + + it('should return null for unsupported board', () => { + expect(parseBoardPath('unknown/1/10/1/40')).toBeNull(); + }); + + it('should return null for too few segments', () => { + expect(parseBoardPath('')).toBeNull(); + expect(parseBoardPath('kilter')).toBeNull(); + expect(parseBoardPath('kilter/1/10/1')).toBeNull(); + }); + + it('should return null for non-numeric layoutId', () => { + expect(parseBoardPath('kilter/abc/10/1/40')).toBeNull(); + }); + + it('should return null for non-numeric sizeId', () => { + expect(parseBoardPath('kilter/1/abc/1/40')).toBeNull(); + }); + + it('should return null for non-numeric angle', () => { + expect(parseBoardPath('kilter/1/10/1/abc')).toBeNull(); + }); + + it('should handle boardPath with trailing segments', () => { + const result = parseBoardPath('tension/2/15/3/55/list'); + expect(result).toEqual({ + boardName: 'tension', + layoutId: 2, + sizeId: 15, + setIds: '3', + angle: 55, + }); + }); + }); + + describe('URL angle segment extraction', () => { + // Test the logic used in persistent-session-context.tsx for URL manipulation + + it('should correctly split boardPath into 5 segments', () => { + const boardPath = 'kilter/1/10/1/40'; + const segments = boardPath.split('/').filter(Boolean); + expect(segments).toHaveLength(5); + expect(segments).toEqual(['kilter', '1', '10', '1', '40']); + }); + + it('should correctly reconstruct URL with new angle', () => { + const currentPathname = '/kilter/1/10/1/40/list'; + const newBoardPath = 'kilter/1/10/1/55'; + + const newBoardPathSegments = newBoardPath.split('/').filter(Boolean); + const currentPathSegments = currentPathname.split('/'); + + // Reconstruct URL + const trailingSegments = currentPathSegments.slice(6); // Everything after the angle + const newPath = ['', ...newBoardPathSegments, ...trailingSegments].join('/'); + + expect(newPath).toBe('/kilter/1/10/1/55/list'); + }); + + it('should preserve trailing climb path', () => { + const currentPathname = '/kilter/1/10/1/40/climb/abc-123'; + const newBoardPath = 'kilter/1/10/1/55'; + + const newBoardPathSegments = newBoardPath.split('/').filter(Boolean); + const currentPathSegments = currentPathname.split('/'); + + const trailingSegments = currentPathSegments.slice(6); + const newPath = ['', ...newBoardPathSegments, ...trailingSegments].join('/'); + + expect(newPath).toBe('/kilter/1/10/1/55/climb/abc-123'); + }); + + it('should handle path without trailing segments', () => { + const currentPathname = '/kilter/1/10/1/40'; + const newBoardPath = 'kilter/1/10/1/55'; + + const newBoardPathSegments = newBoardPath.split('/').filter(Boolean); + const currentPathSegments = currentPathname.split('/'); + + // For path without trailing, slice(6) returns empty array + const trailingSegments = currentPathSegments.slice(6); + const newPath = ['', ...newBoardPathSegments, ...trailingSegments].join('/'); + + expect(newPath).toBe('/kilter/1/10/1/55'); + }); + + it('should require at least 6 segments in pathname', () => { + // pathname.split('/') for '/kilter/1/10/1/40' gives: + // ['', 'kilter', '1', '10', '1', '40'] - 6 segments + const currentPathname = '/kilter/1/10/1/40'; + const segments = currentPathname.split('/'); + expect(segments).toHaveLength(6); + expect(segments.length >= 6).toBe(true); + }); + }); + + describe('updateSessionAngle mutation (integration)', () => { + // These tests require database and Redis setup + // They are marked as skipped/todo for now and should be implemented + // when the test environment is properly configured + + it.todo('should update boardPath in Postgres'); + it.todo('should update boardPath in Redis'); + it.todo('should broadcast AngleChanged event to all session members'); + it.todo('should update queue item stats at the new angle'); + it.todo('should handle version conflicts with retry logic'); + it.todo('should respect rate limiting'); + it.todo('should require session membership'); + }); +}); diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index 80a0d584..1b697352 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -15,7 +15,7 @@ import { playlistMutations } from './playlists/mutations'; import { sessionQueries } from './sessions/queries'; import { sessionMutations } from './sessions/mutations'; import { sessionSubscriptions } from './sessions/subscriptions'; -import { sessionEventResolver } from './sessions/type-resolvers'; +import { sessionEventResolver, sessionTypeResolver } from './sessions/type-resolvers'; import { queueMutations } from './queue/mutations'; import { queueSubscriptions } from './queue/subscriptions'; import { queueEventResolver } from './queue/type-resolvers'; @@ -57,6 +57,7 @@ export const resolvers = { // Field-level resolvers ClimbSearchResult: climbFieldResolvers, + Session: sessionTypeResolver, // Union type resolvers QueueEvent: queueEventResolver, diff --git a/packages/backend/src/graphql/resolvers/sessions/mutations.ts b/packages/backend/src/graphql/resolvers/sessions/mutations.ts index 2eee4839..7a93be19 100644 --- a/packages/backend/src/graphql/resolvers/sessions/mutations.ts +++ b/packages/backend/src/graphql/resolvers/sessions/mutations.ts @@ -1,9 +1,10 @@ import { v4 as uuidv4 } from 'uuid'; -import type { ConnectionContext, SessionEvent } from '@boardsesh/shared-schema'; -import { roomManager } from '../../../services/room-manager'; +import type { ConnectionContext, SessionEvent, QueueEvent, ClimbQueueItem } from '@boardsesh/shared-schema'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; +import { roomManager, VersionConflictError } from '../../../services/room-manager'; import { pubsub } from '../../../pubsub/index'; import { updateContext } from '../../context'; -import { requireAuthenticated, applyRateLimit, validateInput } from '../shared/helpers'; +import { requireAuthenticated, requireSession, applyRateLimit, validateInput, MAX_RETRIES } from '../shared/helpers'; import { SessionIdSchema, BoardPathSchema, @@ -14,11 +15,12 @@ import { ClimbQueueItemSchema, QueueArraySchema, } from '../../../validation/schemas'; -import type { ClimbQueueItem } from '@boardsesh/shared-schema'; import type { CreateSessionInput } from '../shared/types'; import { db } from '../../../db/client'; import { esp32Controllers } from '@boardsesh/db/schema/app'; import { eq } from 'drizzle-orm'; +import { getClimbByUuid } from '../../../db/queries/climbs/get-climb'; +import type { BoardName } from '../../../db/queries/util/table-select'; /** * Auto-authorize all controllers owned by a user for a session. @@ -42,6 +44,83 @@ async function authorizeUserControllersForSession(userId: string, sessionId: str // Debug logging flag - only log in development const DEBUG = process.env.NODE_ENV === 'development'; +/** + * Parse a boardPath into its components. + * boardPath format: board_name/layout_id/size_id/set_ids/angle + */ +function parseBoardPath(boardPath: string): { + boardName: BoardName; + layoutId: number; + sizeId: number; + setIds: string; + angle: number; +} | null { + const parts = boardPath.split('/').filter(Boolean); + if (parts.length < 5) return null; + + const boardName = parts[0] as BoardName; + if (!SUPPORTED_BOARDS.includes(boardName as typeof SUPPORTED_BOARDS[number])) { + return null; + } + + const layoutId = parseInt(parts[1], 10); + const sizeId = parseInt(parts[2], 10); + const angle = parseInt(parts[4], 10); + + // Validate that numeric fields are valid numbers + if (isNaN(layoutId) || isNaN(sizeId) || isNaN(angle)) { + return null; + } + + return { + boardName, + layoutId, + sizeId, + setIds: parts[3], + angle, + }; +} + +/** + * Update a queue item's climb data with stats at a new angle. + * Returns the updated queue item, or the original if fetch fails. + */ +async function updateQueueItemForAngle( + item: ClimbQueueItem, + boardParams: { boardName: BoardName; layoutId: number; sizeId: number }, + newAngle: number +): Promise { + try { + const updatedClimb = await getClimbByUuid({ + board_name: boardParams.boardName, + layout_id: boardParams.layoutId, + size_id: boardParams.sizeId, + angle: newAngle, + climb_uuid: item.climb.uuid, + }); + + if (updatedClimb) { + return { + ...item, + climb: { + ...item.climb, + angle: updatedClimb.angle, + difficulty: updatedClimb.difficulty, + quality_average: updatedClimb.quality_average, + ascensionist_count: updatedClimb.ascensionist_count, + stars: updatedClimb.stars, + difficulty_error: updatedClimb.difficulty_error, + benchmark_difficulty: updatedClimb.benchmark_difficulty, + }, + }; + } + } catch (error) { + console.error(`[Session] Failed to update climb ${item.climb.uuid} for angle ${newAngle}:`, error); + } + // Return original item if fetch fails + return item; +} + export const sessionMutations = { /** * Join an existing session or create a new one @@ -261,4 +340,119 @@ export const sessionMutations = { return true; }, + + /** + * Update the board angle for the current session + * Broadcasts angle change to all session members so they can update their UI + * Also updates climb stats in the queue for the new angle + */ + updateSessionAngle: async (_: unknown, { angle }: { angle: number }, ctx: ConnectionContext) => { + const sessionId = requireSession(ctx); + applyRateLimit(ctx, 10); // Limit angle changes to prevent abuse + + // Validate angle is a reasonable number + if (!Number.isInteger(angle) || angle < 0 || angle > 90) { + throw new Error('Invalid angle: must be an integer between 0 and 90 degrees'); + } + + // Update the session angle in the database and Redis + const result = await roomManager.updateSessionAngle(sessionId, angle); + + // Parse the new boardPath to get board parameters + const boardParams = parseBoardPath(result.boardPath); + + // Broadcast the angle change to all session members first + // This ensures URL updates happen even if queue update has issues + const angleChangedEvent: SessionEvent = { + __typename: 'AngleChanged', + angle: result.angle, + boardPath: result.boardPath, + }; + pubsub.publishSessionEvent(sessionId, angleChangedEvent); + + // Get current queue state + let queueState = await roomManager.getQueueState(sessionId); + + // Warn if we can't parse boardPath but have queue items that need updating + if (!boardParams && (queueState.queue.length > 0 || queueState.currentClimbQueueItem)) { + console.warn(`[updateSessionAngle] Could not parse boardPath "${result.boardPath}" - queue items will have stale stats`); + return true; + } + + // If no board params or no queue items, we're done + if (!boardParams || (queueState.queue.length === 0 && !queueState.currentClimbQueueItem)) { + return true; + } + + // Track which items we've already updated (by UUID) + const updatedItemsMap = new Map(); + + // Retry loop for optimistic locking - handles race conditions when queue is modified + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + if (DEBUG) console.log(`[updateSessionAngle] Attempt ${attempt + 1}/${MAX_RETRIES} - updating ${queueState.queue.length} queue items for angle ${angle}`); + + // Find items that need updating (not already in our cache) + const itemsToUpdate = queueState.queue.filter(item => !updatedItemsMap.has(item.uuid)); + + // Update new items in parallel + if (itemsToUpdate.length > 0) { + const newlyUpdated = await Promise.all( + itemsToUpdate.map(item => updateQueueItemForAngle(item, boardParams, angle)) + ); + // Add to our cache + for (const item of newlyUpdated) { + updatedItemsMap.set(item.uuid, item); + } + } + + // Build the final queue using cached updated items, preserving order from current state + const updatedQueue = queueState.queue.map(item => updatedItemsMap.get(item.uuid) || item); + + // Update current climb if present and not already updated + let updatedCurrentClimb = queueState.currentClimbQueueItem; + if (updatedCurrentClimb) { + if (updatedItemsMap.has(updatedCurrentClimb.uuid)) { + updatedCurrentClimb = updatedItemsMap.get(updatedCurrentClimb.uuid)!; + } else { + updatedCurrentClimb = await updateQueueItemForAngle(updatedCurrentClimb, boardParams, angle); + updatedItemsMap.set(updatedCurrentClimb.uuid, updatedCurrentClimb); + } + } + + try { + // Save the updated queue state with version check + const newQueueState = await roomManager.updateQueueState( + sessionId, + updatedQueue, + updatedCurrentClimb, + queueState.version + ); + + // Send a FullSync event with the updated queue so clients update their queue display + const fullSyncEvent: QueueEvent = { + __typename: 'FullSync', + sequence: newQueueState.sequence, + state: { + sequence: newQueueState.sequence, + stateHash: newQueueState.stateHash, + queue: updatedQueue, + currentClimbQueueItem: updatedCurrentClimb, + }, + }; + pubsub.publishQueueEvent(sessionId, fullSyncEvent); + + return true; // Success + } catch (error) { + if (error instanceof VersionConflictError && attempt < MAX_RETRIES - 1) { + if (DEBUG) console.log(`[updateSessionAngle] Version conflict, retrying (attempt ${attempt + 1}/${MAX_RETRIES})`); + // Re-fetch queue state for next attempt - our cached updates will be reused + queueState = await roomManager.getQueueState(sessionId); + continue; + } + throw error; // Re-throw if not a version conflict or max retries exceeded + } + } + + return true; + }, }; diff --git a/packages/backend/src/graphql/resolvers/sessions/type-resolvers.ts b/packages/backend/src/graphql/resolvers/sessions/type-resolvers.ts index fceedad1..942e5f89 100644 --- a/packages/backend/src/graphql/resolvers/sessions/type-resolvers.ts +++ b/packages/backend/src/graphql/resolvers/sessions/type-resolvers.ts @@ -9,3 +9,24 @@ export const sessionEventResolver = { return obj.__typename; }, }; + +/** + * Session type resolver + * Computes derived fields like angle from the boardPath + */ +export const sessionTypeResolver = { + /** + * Extract angle from boardPath + * boardPath format: board_name/layout_id/size_id/set_ids/angle + * The angle is always the 5th segment (index 4 after filtering empty strings) + */ + angle: (session: { boardPath: string }) => { + const pathParts = session.boardPath.split('/').filter(Boolean); + // Angle is at index 4: [board_name, layout_id, size_id, set_ids, angle] + if (pathParts.length < 5) { + return 40; // Default if path is malformed + } + const angle = parseInt(pathParts[4], 10); + return isNaN(angle) ? 40 : angle; // Default to 40 if parsing fails + }, +}; diff --git a/packages/backend/src/services/redis-session-store.ts b/packages/backend/src/services/redis-session-store.ts index 0cca5f9d..041955ea 100644 --- a/packages/backend/src/services/redis-session-store.ts +++ b/packages/backend/src/services/redis-session-store.ts @@ -244,6 +244,24 @@ export class RedisSessionStore { await multi.exec(); } + /** + * Update the board path for a session (used when angle changes). + */ + async updateBoardPath(sessionId: string, boardPath: string): Promise { + const key = `boardsesh:session:${sessionId}`; + const multi = this.redis.multi(); + + multi.hmset(key, { + boardPath: boardPath, + lastActivity: Date.now().toString(), + }); + + multi.expire(key, this.TTL); + multi.zadd('boardsesh:session:recent', Date.now(), sessionId); + + await multi.exec(); + } + /** * Delete session from Redis (when explicitly ended). */ diff --git a/packages/backend/src/services/room-manager.ts b/packages/backend/src/services/room-manager.ts index 302bea84..26bdc4a8 100644 --- a/packages/backend/src/services/room-manager.ts +++ b/packages/backend/src/services/room-manager.ts @@ -610,6 +610,43 @@ class RoomManager { } } + /** + * Update the session angle. Updates the boardPath in both Postgres and Redis. + * Returns the new boardPath with the updated angle. + */ + async updateSessionAngle(sessionId: string, newAngle: number): Promise<{ boardPath: string; angle: number }> { + // Get the current session to get its boardPath + const session = await this.getSessionById(sessionId); + if (!session) { + throw new Error(`Session ${sessionId} not found`); + } + + // Parse and update the boardPath + // Format: board_name/layout_id/size_id/set_ids/angle + // Filter empty strings to handle leading slashes consistently + const pathParts = session.boardPath.split('/').filter(Boolean); + if (pathParts.length < 5) { + throw new Error(`Invalid boardPath format: ${session.boardPath}`); + } + + // Replace the angle at index 4 (5th segment) + pathParts[4] = newAngle.toString(); + const newBoardPath = pathParts.join('/'); + + // Update Postgres + await db + .update(sessions) + .set({ boardPath: newBoardPath, lastActivity: new Date() }) + .where(eq(sessions.id, sessionId)); + + // Update Redis + if (this.redisStore) { + await this.redisStore.updateBoardPath(sessionId, newBoardPath); + } + + return { boardPath: newBoardPath, angle: newAngle }; + } + async updateQueueState( sessionId: string, queue: ClimbQueueItem[], diff --git a/packages/shared-schema/src/operations.ts b/packages/shared-schema/src/operations.ts index bca254b9..ca1ffce2 100644 --- a/packages/shared-schema/src/operations.ts +++ b/packages/shared-schema/src/operations.ts @@ -47,6 +47,7 @@ export const JOIN_SESSION = ` id name boardPath + angle clientId isLeader users { @@ -138,6 +139,7 @@ export const CREATE_SESSION = ` id name boardPath + angle clientId isLeader users { @@ -160,6 +162,12 @@ export const CREATE_SESSION = ` } `; +export const UPDATE_SESSION_ANGLE = ` + mutation UpdateSessionAngle($angle: Int!) { + updateSessionAngle(angle: $angle) + } +`; + // Subscriptions export const SESSION_UPDATES = ` subscription SessionUpdates($sessionId: ID!) { @@ -183,6 +191,10 @@ export const SESSION_UPDATES = ` reason newPath } + ... on AngleChanged { + angle + boardPath + } } } `; diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index b8358b88..9aabc63e 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -171,6 +171,8 @@ export const typeDefs = /* GraphQL */ ` name: String "Board configuration path (board_name/layout_id/size_id/set_ids/angle)" boardPath: String! + "Current board angle in degrees (extracted from boardPath)" + angle: Int! "Users currently in the session" users: [SessionUser!]! "Current queue state" @@ -1133,6 +1135,12 @@ export const typeDefs = /* GraphQL */ ` """ updateUsername(username: String!, avatarUrl: String): Boolean! + """ + Update the board angle for the current session. + Broadcasts angle change to all session members. + """ + updateSessionAngle(angle: Int!): Boolean! + """ Add a climb to the queue. Optional position parameter for inserting at specific index. @@ -1295,7 +1303,7 @@ export const typeDefs = /* GraphQL */ ` """ Union of possible session events. """ - union SessionEvent = UserJoined | UserLeft | LeaderChanged | SessionEnded + union SessionEvent = UserJoined | UserLeft | LeaderChanged | SessionEnded | AngleChanged """ Event when a user joins the session. @@ -1331,6 +1339,16 @@ export const typeDefs = /* GraphQL */ ` newPath: String } + """ + Event when the session angle changes. + """ + type AngleChanged { + "New angle in degrees" + angle: Int! + "New board path with updated angle" + boardPath: String! + } + """ Union of possible queue events. """ diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 39190e8b..9fb05a33 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -320,7 +320,8 @@ export type SessionEvent = | { __typename: 'UserJoined'; user: SessionUser } | { __typename: 'UserLeft'; userId: string } | { __typename: 'LeaderChanged'; leaderId: string } - | { __typename: 'SessionEnded'; reason: string; newPath?: string }; + | { __typename: 'SessionEnded'; reason: string; newPath?: string } + | { __typename: 'AngleChanged'; angle: number; boardPath: string }; export type ConnectionContext = { connectionId: string; diff --git a/packages/web/app/components/board-page/angle-selector.tsx b/packages/web/app/components/board-page/angle-selector.tsx index fb9cf376..a4b36bc0 100644 --- a/packages/web/app/components/board-page/angle-selector.tsx +++ b/packages/web/app/components/board-page/angle-selector.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useRef, useEffect } from 'react'; -import { Button, Drawer, Spin, Typography, Flex, Row, Col, Card, Alert } from 'antd'; +import { Button, Drawer, Spin, Typography, Flex, Row, Col, Card, Alert, message } from 'antd'; import { useRouter, usePathname } from 'next/navigation'; import { track } from '@vercel/analytics'; import useSWR from 'swr'; @@ -9,6 +9,7 @@ import { ANGLES } from '@/app/lib/board-data'; import { BoardName, Climb } from '@/app/lib/types'; import { ClimbStatsForAngle } from '@/app/lib/data/queries'; import { themeTokens } from '@/app/theme/theme-config'; +import { usePersistentSession } from '../persistent-session/persistent-session-context'; const { Text } = Typography; @@ -23,6 +24,7 @@ export default function AngleSelector({ boardName, currentAngle, currentClimb }: const router = useRouter(); const pathname = usePathname(); const currentAngleRef = useRef(null); + const { activeSession, updateSessionAngle } = usePersistentSession(); // Build the API URL for fetching climb stats const climbStatsUrl = currentClimb @@ -59,19 +61,32 @@ export default function AngleSelector({ boardName, currentAngle, currentClimb }: } }, [isDrawerOpen]); - const handleAngleChange = (newAngle: number) => { + const handleAngleChange = async (newAngle: number) => { track('Angle Changed', { angle: newAngle, + inSession: !!activeSession, }); - // Replace the current angle in the URL with the new one - const pathSegments = pathname.split('/'); - const angleIndex = pathSegments.findIndex((segment) => segment === currentAngle.toString()); - - if (angleIndex !== -1) { - pathSegments[angleIndex] = newAngle.toString(); - const newPath = pathSegments.join('/'); - router.push(newPath); + if (activeSession) { + // In a session - use the mutation to update angle for all users + // The URL will be updated by the AngleChanged event handler in PersistentSessionContext + try { + await updateSessionAngle(newAngle); + } catch (error) { + console.error('[AngleSelector] Failed to update session angle:', error); + message.error('Failed to change angle. Please try again.'); + return; // Don't close drawer on error so user can retry + } + } else { + // Not in a session - just update the URL locally + const pathSegments = pathname.split('/'); + const angleIndex = pathSegments.findIndex((segment) => segment === currentAngle.toString()); + + if (angleIndex !== -1) { + pathSegments[angleIndex] = newAngle.toString(); + const newPath = pathSegments.join('/'); + router.push(newPath); + } } setIsDrawerOpen(false); 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 b1a48d66..56acd5e4 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { usePathname } from 'next/navigation'; // Used by useIsOnBoardRoute +import { usePathname, useRouter } from 'next/navigation'; import { createGraphQLClient, execute, subscribe, Client } from '../graphql-queue/graphql-client'; import { JOIN_SESSION, @@ -11,6 +11,7 @@ import { SET_CURRENT_CLIMB, MIRROR_CURRENT_CLIMB, SET_QUEUE, + UPDATE_SESSION_ANGLE, SESSION_UPDATES, QUEUE_UPDATES, EVENTS_REPLAY, @@ -72,6 +73,7 @@ export interface Session { id: string; name: string | null; boardPath: string; + angle: number; users: SessionUser[]; queueState: QueueState; isLeader: boolean; @@ -136,6 +138,7 @@ export interface PersistentSessionContextType { clientId: string | null; isLeader: boolean; users: SessionUser[]; + sessionAngle: number | null; // Queue state synced from backend currentClimbQueueItem: LocalClimbQueueItem | null; @@ -170,6 +173,7 @@ export interface PersistentSessionContextType { setCurrentClimb: (item: LocalClimbQueueItem | null, shouldAddToQueue?: boolean, correlationId?: string) => Promise; mirrorCurrentClimb: (mirrored: boolean) => Promise; setQueue: (queue: LocalClimbQueueItem[], currentClimbQueueItem?: LocalClimbQueueItem | null) => Promise; + updateSessionAngle: (angle: number) => Promise; // Event subscription for board-level components subscribeToQueueEvents: (callback: (event: SubscriptionQueueEvent) => void) => () => void; @@ -184,6 +188,8 @@ const PersistentSessionContext = createContext = ({ children }) => { const { token: wsAuthToken, isLoading: isAuthLoading } = useWsAuthToken(); const { username, avatarUrl } = usePartyProfile(); + const router = useRouter(); + const pathname = usePathname(); // Use refs for values that shouldn't trigger reconnection // These values are used during connection but changes shouldn't cause reconnect @@ -432,14 +438,53 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> case 'SessionEnded': if (DEBUG) console.log('[PersistentSession] Session ended:', event.reason); return prev; + case 'AngleChanged': + if (DEBUG) console.log('[PersistentSession] Angle changed:', event.angle, event.boardPath); + return { + ...prev, + angle: event.angle, + boardPath: event.boardPath, + }; default: return prev; } }); + // Handle AngleChanged navigation separately (after state update) + if (event.__typename === 'AngleChanged') { + // Update the URL to reflect the new angle + // Use the boardPath from the event which has the correct structure + // boardPath format: board_name/layout_id/size_id/set_ids/angle (5 segments) + const newBoardPathSegments = event.boardPath.split('/').filter(Boolean); + const currentPathSegments = pathname.split('/'); + + // The pathname structure is: ['', board_name, layout_id, size_id, set_ids, angle, ...rest] + // After split('/'), we need at least 6 segments (empty + 5 board path segments) + // We replace segments 1-5 with the new boardPath segments + if (newBoardPathSegments.length === 5 && currentPathSegments.length >= 6) { + // Keep the leading empty string and any trailing segments (like /list, /climb/uuid) + const trailingSegments = currentPathSegments.slice(6); // Everything after the angle + const newPath = ['', ...newBoardPathSegments, ...trailingSegments].join('/'); + + if (newPath !== pathname) { + // Preserve search params (like session ID) + const searchParams = new URLSearchParams(window.location.search); + const newUrl = searchParams.toString() ? `${newPath}?${searchParams.toString()}` : newPath; + router.replace(newUrl); + } + } else if (DEBUG) { + console.warn('[PersistentSession] Could not update URL for angle change:', { + pathname, + newBoardPath: event.boardPath, + pathSegmentCount: currentPathSegments.length, + boardPathSegmentCount: newBoardPathSegments.length, + }); + } + } + // Notify external subscribers notifySessionSubscribers(event); - }, [notifySessionSubscribers]); + }, [notifySessionSubscribers, pathname, router]); // Connect to session when activeSession changes useEffect(() => { @@ -928,6 +973,17 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> [client, session], ); + const updateSessionAngleMutation = useCallback( + async (angle: number) => { + if (!client || !session) throw new Error('Not connected to session'); + await execute(client, { + query: UPDATE_SESSION_ANGLE, + variables: { angle }, + }); + }, + [client, session], + ); + // Event subscription functions const subscribeToQueueEvents = useCallback((callback: (event: SubscriptionQueueEvent) => void) => { queueEventSubscribersRef.current.add(callback); @@ -961,6 +1017,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> clientId: session?.clientId ?? null, isLeader: session?.isLeader ?? false, users: session?.users ?? [], + sessionAngle: session?.angle ?? null, currentClimbQueueItem, queue, localQueue, @@ -977,6 +1034,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> setCurrentClimb: setCurrentClimbMutation, mirrorCurrentClimb: mirrorCurrentClimbMutation, setQueue: setQueueMutation, + updateSessionAngle: updateSessionAngleMutation, subscribeToQueueEvents, subscribeToSessionEvents, triggerResync, @@ -1003,6 +1061,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> setCurrentClimbMutation, mirrorCurrentClimbMutation, setQueueMutation, + updateSessionAngleMutation, subscribeToQueueEvents, subscribeToSessionEvents, triggerResync,