From 34c8d7e9aa29b3147368c80733a06b08f1fb711f Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Mon, 11 Aug 2025 11:20:50 +1000 Subject: [PATCH 1/4] Add circuit/list filtering support to climbing board app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive circuit support with filtering and display features: Features: - Circuit dropdown filter in search form (logged-in users only) - Clickable circuit tags on climb cards with color coding - Circuit filtering integrated with existing search functionality - Support for both user circuits and public circuits Technical Implementation: - Added circuit database queries and API endpoints - Extended search parameters to support circuitUuids filtering - Added circuits_climbs sync to user data sync process - Created custom hooks for fetching circuits and climb circuits - Updated climb search queries to filter by circuit membership - Enhanced UI components with circuit selection and display Database Changes: - Added circuitsClimbs table support to table selection utility - Implemented circuit-climb relationship queries with position ordering - Added circuits_climbs to USER_TABLES for proper syncing UI/UX: - Multi-select circuit dropdown with color indicators - Circuit tags show on climb cards when user is authenticated - Tags are clickable to quickly filter by circuit - Proper loading states and error handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../[board]/circuits/[uuid]/climbs/route.ts | 28 +++ app/api/internal/[board]/circuits/route.ts | 45 +++++ .../[board]/climbs/[uuid]/circuits/route.ts | 28 +++ app/components/climb-card/climb-card.tsx | 40 +++- .../ui-searchparams-provider.tsx | 1 + .../search-drawer/basic-search-form.tsx | 38 +++- app/hooks/use-circuits.ts | 18 ++ app/hooks/use-climb-circuits.ts | 18 ++ app/lib/api-wrappers/aurora/types.ts | 1 + app/lib/data-sync/aurora/user-sync.ts | 20 ++ app/lib/db/queries/circuits/get-circuits.ts | 176 ++++++++++++++++++ .../db/queries/climbs/create-climb-filters.ts | 16 +- app/lib/db/queries/util/table-select.ts | 5 + app/lib/types.ts | 1 + app/lib/url-utils.ts | 10 + 15 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 app/api/internal/[board]/circuits/[uuid]/climbs/route.ts create mode 100644 app/api/internal/[board]/circuits/route.ts create mode 100644 app/api/internal/[board]/climbs/[uuid]/circuits/route.ts create mode 100644 app/hooks/use-circuits.ts create mode 100644 app/hooks/use-climb-circuits.ts create mode 100644 app/lib/db/queries/circuits/get-circuits.ts diff --git a/app/api/internal/[board]/circuits/[uuid]/climbs/route.ts b/app/api/internal/[board]/circuits/[uuid]/climbs/route.ts new file mode 100644 index 0000000..b5cd17d --- /dev/null +++ b/app/api/internal/[board]/circuits/[uuid]/climbs/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { BoardName } from '@/app/lib/types'; +import { getClimbsByCircuit } from '@/app/lib/db/queries/circuits/get-circuits'; + +type Params = Promise<{ + board: BoardName; + uuid: string; +}>; + +export async function GET( + request: NextRequest, + segmentData: { params: Params } +): Promise { + const params = await segmentData.params; + const { board, uuid } = params; + + try { + const climbUuids = await getClimbsByCircuit(board, uuid); + + return NextResponse.json({ climbUuids }); + } catch (error) { + console.error('Error fetching circuit climbs:', error); + return NextResponse.json( + { error: 'Failed to fetch circuit climbs' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/internal/[board]/circuits/route.ts b/app/api/internal/[board]/circuits/route.ts new file mode 100644 index 0000000..7cefbbb --- /dev/null +++ b/app/api/internal/[board]/circuits/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { BoardName } from '@/app/lib/types'; +import { getUserAndPublicCircuits, getCircuitsByUser } from '@/app/lib/db/queries/circuits/get-circuits'; +import { getSession } from '@/app/lib/session'; +import { cookies } from 'next/headers'; + +type Params = Promise<{ + board: BoardName; +}>; + +export async function GET( + request: NextRequest, + segmentData: { params: Params } +): Promise { + const params = await segmentData.params; + const { board } = params; + + try { + const cookieStore = await cookies(); + const session = await getSession(cookieStore, board); + + let userId: number | undefined; + if (session) { + userId = session.userId; + } + + const searchParams = request.nextUrl.searchParams; + const userOnly = searchParams.get('userOnly') === 'true'; + + let circuits; + if (userOnly && userId) { + circuits = await getCircuitsByUser(board, userId); + } else { + circuits = await getUserAndPublicCircuits(board, userId); + } + + return NextResponse.json({ circuits }); + } catch (error) { + console.error('Error fetching circuits:', error); + return NextResponse.json( + { error: 'Failed to fetch circuits' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/internal/[board]/climbs/[uuid]/circuits/route.ts b/app/api/internal/[board]/climbs/[uuid]/circuits/route.ts new file mode 100644 index 0000000..f9b676f --- /dev/null +++ b/app/api/internal/[board]/climbs/[uuid]/circuits/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { BoardName } from '@/app/lib/types'; +import { getCircuitsForClimb } from '@/app/lib/db/queries/circuits/get-circuits'; + +type Params = Promise<{ + board: BoardName; + uuid: string; +}>; + +export async function GET( + request: NextRequest, + segmentData: { params: Params } +): Promise { + const params = await segmentData.params; + const { board, uuid } = params; + + try { + const circuits = await getCircuitsForClimb(board, uuid); + + return NextResponse.json({ circuits }); + } catch (error) { + console.error('Error fetching climb circuits:', error); + return NextResponse.json( + { error: 'Failed to fetch climb circuits' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/components/climb-card/climb-card.tsx b/app/components/climb-card/climb-card.tsx index b3f495f..c1d6f97 100644 --- a/app/components/climb-card/climb-card.tsx +++ b/app/components/climb-card/climb-card.tsx @@ -2,11 +2,15 @@ import React from 'react'; import Card from 'antd/es/card'; +import { Tag } from 'antd'; import { CopyrightOutlined } from '@ant-design/icons'; import ClimbCardCover from './climb-card-cover'; import { Climb, BoardDetails } from '@/app/lib/types'; import ClimbCardActions from './climb-card-actions'; +import { useClimbCircuits } from '@/app/hooks/use-climb-circuits'; +import { useBoardProvider } from '@/app/components/board-provider/board-provider-context'; +import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider'; type ClimbCardProps = { climb?: Climb; @@ -18,6 +22,10 @@ type ClimbCardProps = { }; const ClimbCard = ({ climb, boardDetails, onCoverClick, selected, actions }: ClimbCardProps) => { + const { boardName, isAuthenticated } = useBoardProvider(); + const { updateFilters } = useUISearchParams(); + const { circuits } = useClimbCircuits(boardName, climb?.uuid || null, Boolean(isAuthenticated && climb?.uuid)); + const cover = ; const cardTitle = climb ? ( @@ -49,7 +57,37 @@ const ClimbCard = ({ climb, boardDetails, onCoverClick, selected, actions }: Cli actions={actions || ClimbCardActions({ climb, boardDetails })} > {/* TODO: Make a link to the list with the setter_name filter */} - {climb ? `By ${climb.setter_username} - ${climb.ascensionist_count} ascents` : null} + {climb && ( +
+
0 ? '8px' : '0' }}> + By {climb.setter_username} - {climb.ascensionist_count} ascents +
+ {circuits.length > 0 && ( +
+ {circuits.map((circuit) => ( + { + e.stopPropagation(); + updateFilters({ circuitUuids: [circuit.uuid] }); + }} + title={`Filter by circuit: ${circuit.name}`} + > + {circuit.name || 'Unnamed Circuit'} + + ))} +
+ )} +
+ )} {cover} ); diff --git a/app/components/queue-control/ui-searchparams-provider.tsx b/app/components/queue-control/ui-searchparams-provider.tsx index 42c2503..5eb4c58 100644 --- a/app/components/queue-control/ui-searchparams-provider.tsx +++ b/app/components/queue-control/ui-searchparams-provider.tsx @@ -41,6 +41,7 @@ export const UISearchParamsProvider: React.FC<{ children: React.ReactNode }> = ( if (uiSearchParams.hideCompleted) activeFilters.push('hideCompleted'); if (uiSearchParams.showOnlyAttempted) activeFilters.push('showOnlyAttempted'); if (uiSearchParams.showOnlyCompleted) activeFilters.push('showOnlyCompleted'); + if (uiSearchParams.circuitUuids && uiSearchParams.circuitUuids.length > 0) activeFilters.push('circuits'); if (activeFilters.length > 0) { track('Climb Search Performed', { diff --git a/app/components/search-drawer/basic-search-form.tsx b/app/components/search-drawer/basic-search-form.tsx index c802b79..5054511 100644 --- a/app/components/search-drawer/basic-search-form.tsx +++ b/app/components/search-drawer/basic-search-form.tsx @@ -5,13 +5,15 @@ import { Form, InputNumber, Row, Col, Select, Input, Switch, Alert, Typography } import { TENSION_KILTER_GRADES } from '@/app/lib/board-data'; import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider'; import { useBoardProvider } from '@/app/components/board-provider/board-provider-context'; +import { useCircuits } from '@/app/hooks/use-circuits'; import SearchClimbNameInput from './search-climb-name-input'; const { Title } = Typography; const BasicSearchForm: React.FC = () => { const { uiSearchParams, updateFilters } = useUISearchParams(); - const { token, user_id } = useBoardProvider(); + const { token, user_id, boardName } = useBoardProvider(); + const { circuits, isLoading: circuitsLoading } = useCircuits(boardName, Boolean(token && user_id)); const grades = TENSION_KILTER_GRADES; const isLoggedIn = token && user_id; @@ -194,6 +196,40 @@ const BasicSearchForm: React.FC = () => { updateFilters({ settername: e.target.value })} /> + {isLoggedIn && ( + + + + )} + Personal Progress diff --git a/app/hooks/use-circuits.ts b/app/hooks/use-circuits.ts new file mode 100644 index 0000000..1373015 --- /dev/null +++ b/app/hooks/use-circuits.ts @@ -0,0 +1,18 @@ +import useSWR from 'swr'; +import { BoardName } from '@/app/lib/types'; +import { Circuit } from '@/app/lib/db/queries/circuits/get-circuits'; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +export function useCircuits(boardName: BoardName | null, enabled: boolean = true) { + const { data, error, isLoading } = useSWR( + enabled && boardName ? `/api/internal/${boardName}/circuits` : null, + fetcher + ); + + return { + circuits: (data?.circuits || []) as Circuit[], + isLoading, + error, + }; +} \ No newline at end of file diff --git a/app/hooks/use-climb-circuits.ts b/app/hooks/use-climb-circuits.ts new file mode 100644 index 0000000..7feb19f --- /dev/null +++ b/app/hooks/use-climb-circuits.ts @@ -0,0 +1,18 @@ +import useSWR from 'swr'; +import { BoardName, ClimbUuid } from '@/app/lib/types'; +import { Circuit } from '@/app/lib/db/queries/circuits/get-circuits'; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +export function useClimbCircuits(boardName: BoardName | null, climbUuid: ClimbUuid | null, enabled: boolean = true) { + const { data, error, isLoading } = useSWR( + enabled && boardName && climbUuid ? `/api/internal/${boardName}/climbs/${climbUuid}/circuits` : null, + fetcher + ); + + return { + circuits: (data?.circuits || []) as Circuit[], + isLoading, + error, + }; +} \ No newline at end of file diff --git a/app/lib/api-wrappers/aurora/types.ts b/app/lib/api-wrappers/aurora/types.ts index 712080e..c3c3f42 100644 --- a/app/lib/api-wrappers/aurora/types.ts +++ b/app/lib/api-wrappers/aurora/types.ts @@ -201,6 +201,7 @@ export const USER_TABLES = [ 'bids', 'tags', 'circuits', + 'circuits_climbs', ]; export const SHARED_SYNC_TABLES = [ 'products', diff --git a/app/lib/data-sync/aurora/user-sync.ts b/app/lib/data-sync/aurora/user-sync.ts index a4a3966..42ee074 100644 --- a/app/lib/data-sync/aurora/user-sync.ts +++ b/app/lib/data-sync/aurora/user-sync.ts @@ -252,6 +252,26 @@ async function upsertTableData( break; } + case 'circuits_climbs': { + const circuitsClimbsSchema = getTable('circuitsClimbs', boardName); + for (const item of data) { + await db + .insert(circuitsClimbsSchema) + .values({ + circuitUuid: item.circuit_uuid, + climbUuid: item.climb_uuid, + position: Number(item.position || 0), + }) + .onConflictDoUpdate({ + target: [circuitsClimbsSchema.circuitUuid, circuitsClimbsSchema.climbUuid], + set: { + position: Number(item.position || 0), + }, + }); + } + break; + } + default: console.warn(`No specific upsert logic for table: ${tableName}`); break; diff --git a/app/lib/db/queries/circuits/get-circuits.ts b/app/lib/db/queries/circuits/get-circuits.ts new file mode 100644 index 0000000..a5ccd0e --- /dev/null +++ b/app/lib/db/queries/circuits/get-circuits.ts @@ -0,0 +1,176 @@ +import { getPool } from '@/app/lib/db/db'; +import { BoardName } from '@/app/lib/types'; +import { getTable } from '../util/table-select'; +import { drizzle } from 'drizzle-orm/neon-serverless'; +import { eq, or, desc } from 'drizzle-orm'; + +export interface Circuit { + uuid: string; + name: string | null; + description: string | null; + color: string | null; + userId: number | null; + isPublic: boolean | null; + createdAt: string | null; + updatedAt: string | null; +} + +export interface CircuitWithClimbs extends Circuit { + climbUuids: string[]; +} + +export async function getCircuitsByUser(boardName: BoardName, userId: number): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + const db = drizzle(client); + const circuitsTable = getTable('circuits', boardName); + + const circuits = await db + .select() + .from(circuitsTable) + .where(eq(circuitsTable.userId, userId)) + .orderBy(desc(circuitsTable.updatedAt)); + + return circuits as Circuit[]; + } finally { + client.release(); + } +} + +export async function getPublicCircuits(boardName: BoardName, limit = 50): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + const db = drizzle(client); + const circuitsTable = getTable('circuits', boardName); + + const circuits = await db + .select() + .from(circuitsTable) + .where(eq(circuitsTable.isPublic, true)) + .orderBy(desc(circuitsTable.updatedAt)) + .limit(limit); + + return circuits as Circuit[]; + } finally { + client.release(); + } +} + +export async function getUserAndPublicCircuits(boardName: BoardName, userId?: number): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + const db = drizzle(client); + const circuitsTable = getTable('circuits', boardName); + + let whereCondition; + if (userId) { + whereCondition = or( + eq(circuitsTable.userId, userId), + eq(circuitsTable.isPublic, true) + ); + } else { + whereCondition = eq(circuitsTable.isPublic, true); + } + + const circuits = await db + .select() + .from(circuitsTable) + .where(whereCondition) + .orderBy(desc(circuitsTable.updatedAt)); + + return circuits as Circuit[]; + } finally { + client.release(); + } +} + +export async function getClimbsByCircuit(boardName: BoardName, circuitUuid: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + const db = drizzle(client); + const circuitsClimbsTable = getTable('circuitsClimbs', boardName); + + const climbs = await db + .select({ + climbUuid: circuitsClimbsTable.climbUuid, + position: circuitsClimbsTable.position, + }) + .from(circuitsClimbsTable) + .where(eq(circuitsClimbsTable.circuitUuid, circuitUuid)) + .orderBy(circuitsClimbsTable.position || circuitsClimbsTable.climbUuid); // fallback to uuid if position is null + + return climbs.map((c) => c.climbUuid); + } finally { + client.release(); + } +} + +export async function getCircuitsForClimb(boardName: BoardName, climbUuid: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + const db = drizzle(client); + const circuitsTable = getTable('circuits', boardName); + const circuitsClimbsTable = getTable('circuitsClimbs', boardName); + + const circuits = await db + .select({ + uuid: circuitsTable.uuid, + name: circuitsTable.name, + description: circuitsTable.description, + color: circuitsTable.color, + userId: circuitsTable.userId, + isPublic: circuitsTable.isPublic, + createdAt: circuitsTable.createdAt, + updatedAt: circuitsTable.updatedAt, + }) + .from(circuitsTable) + .innerJoin( + circuitsClimbsTable, + eq(circuitsTable.uuid, circuitsClimbsTable.circuitUuid) + ) + .where(eq(circuitsClimbsTable.climbUuid, climbUuid)); + + return circuits as Circuit[]; + } finally { + client.release(); + } +} + +export async function getCircuitWithClimbs(boardName: BoardName, circuitUuid: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + const db = drizzle(client); + const circuitsTable = getTable('circuits', boardName); + + const [circuit] = await db + .select() + .from(circuitsTable) + .where(eq(circuitsTable.uuid, circuitUuid)) + .limit(1); + + if (!circuit) { + return null; + } + + const climbUuids = await getClimbsByCircuit(boardName, circuitUuid); + + return { + ...(circuit as Circuit), + climbUuids, + }; + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/app/lib/db/queries/climbs/create-climb-filters.ts b/app/lib/db/queries/climbs/create-climb-filters.ts index 87ce9c4..13a5037 100644 --- a/app/lib/db/queries/climbs/create-climb-filters.ts +++ b/app/lib/db/queries/climbs/create-climb-filters.ts @@ -140,6 +140,19 @@ export const createClimbFilters = ( } } + // Circuit filter conditions + const circuitConditions: SQL[] = []; + if (searchParams.circuitUuids && searchParams.circuitUuids.length > 0) { + const circuitsClimbsTable = getTableName(params.board_name, 'circuitsClimbs'); + circuitConditions.push( + sql`EXISTS ( + SELECT 1 FROM ${sql.identifier(circuitsClimbsTable)} + WHERE climb_uuid = ${tables.climbs.uuid} + AND circuit_uuid = ANY(${searchParams.circuitUuids}) + )` + ); + } + // User-specific logbook data selectors const getUserLogbookSelects = () => { const ascentsTable = getTableName(params.board_name, 'ascents'); @@ -188,7 +201,7 @@ export const createClimbFilters = ( return { // Helper function to get all climb filtering conditions - getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...holdConditions, ...personalProgressConditions], + getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...holdConditions, ...personalProgressConditions, ...circuitConditions], // Size-specific conditions getSizeConditions: () => sizeConditions, @@ -221,6 +234,7 @@ export const createClimbFilters = ( holdConditions, sizeConditions, personalProgressConditions, + circuitConditions, anyHolds, notHolds, }; diff --git a/app/lib/db/queries/util/table-select.ts b/app/lib/db/queries/util/table-select.ts index 4ba5e7c..5a7af76 100644 --- a/app/lib/db/queries/util/table-select.ts +++ b/app/lib/db/queries/util/table-select.ts @@ -7,6 +7,7 @@ import { kilterLayouts, kilterUsers, kilterCircuits, + kilterCircuitsClimbs, kilterAscents, kilterBids, kilterClimbStatsHistory, @@ -17,6 +18,7 @@ import { tensionLayouts, tensionUsers, tensionCircuits, + tensionCircuitsClimbs, tensionAscents, tensionBids, tensionClimbStatsHistory, @@ -49,6 +51,7 @@ export type TableSet = { layouts: typeof kilterLayouts | typeof tensionLayouts; users: typeof kilterUsers | typeof tensionUsers; circuits: typeof kilterCircuits | typeof tensionCircuits; + circuitsClimbs: typeof kilterCircuitsClimbs | typeof tensionCircuitsClimbs; ascents: typeof kilterAscents | typeof tensionAscents; bids: typeof kilterBids | typeof tensionBids; climbStatsHistory: typeof kilterClimbStatsHistory | typeof tensionClimbStatsHistory; @@ -72,6 +75,7 @@ const BOARD_TABLES: Record = { layouts: kilterLayouts, users: kilterUsers, circuits: kilterCircuits, + circuitsClimbs: kilterCircuitsClimbs, ascents: kilterAscents, bids: kilterBids, climbStatsHistory: kilterClimbStatsHistory, @@ -92,6 +96,7 @@ const BOARD_TABLES: Record = { layouts: tensionLayouts, users: tensionUsers, circuits: tensionCircuits, + circuitsClimbs: tensionCircuitsClimbs, ascents: tensionAscents, bids: tensionBids, climbStatsHistory: tensionClimbStatsHistory, diff --git a/app/lib/types.ts b/app/lib/types.ts index f3671ff..afa3909 100644 --- a/app/lib/types.ts +++ b/app/lib/types.ts @@ -93,6 +93,7 @@ export type SearchRequest = { hideCompleted: boolean; showOnlyAttempted: boolean; showOnlyCompleted: boolean; + circuitUuids: string[]; [key: `hold_${number}`]: HoldFilterValue; // Allow dynamic hold keys directly in the search params }; diff --git a/app/lib/url-utils.ts b/app/lib/url-utils.ts index ab457f0..6258c65 100644 --- a/app/lib/url-utils.ts +++ b/app/lib/url-utils.ts @@ -54,6 +54,7 @@ export const searchParamsToUrlParams = ({ hideCompleted, showOnlyAttempted, showOnlyCompleted, + circuitUuids, page, pageSize, }: SearchRequestPagination): URLSearchParams => { @@ -119,6 +120,11 @@ export const searchParamsToUrlParams = ({ }); } + // Add circuit UUIDs if they exist + if (circuitUuids && circuitUuids.length > 0) { + params.circuitUuids = circuitUuids.join(','); + } + return new URLSearchParams(params); }; export const DEFAULT_SEARCH_PARAMS: SearchRequestPagination = { @@ -138,6 +144,7 @@ export const DEFAULT_SEARCH_PARAMS: SearchRequestPagination = { hideCompleted: false, showOnlyAttempted: false, showOnlyCompleted: false, + circuitUuids: [], page: 0, pageSize: PAGE_LIMIT, }; @@ -149,6 +156,8 @@ export const urlParamsToSearchParams = (urlParams: URLSearchParams): SearchReque .map(([key, value]) => [key.replace('hold_', ''), value]), ); + const circuitUuids = urlParams.get('circuitUuids')?.split(',').filter(Boolean) ?? DEFAULT_SEARCH_PARAMS.circuitUuids; + return { ...DEFAULT_SEARCH_PARAMS, gradeAccuracy: Number(urlParams.get('gradeAccuracy') ?? DEFAULT_SEARCH_PARAMS.gradeAccuracy), @@ -168,6 +177,7 @@ export const urlParamsToSearchParams = (urlParams: URLSearchParams): SearchReque hideCompleted: urlParams.get('hideCompleted') === 'true', showOnlyAttempted: urlParams.get('showOnlyAttempted') === 'true', showOnlyCompleted: urlParams.get('showOnlyCompleted') === 'true', + circuitUuids, page: Number(urlParams.get('page') ?? DEFAULT_SEARCH_PARAMS.page), pageSize: Number(urlParams.get('pageSize') ?? DEFAULT_SEARCH_PARAMS.pageSize), }; From d3463604ed5afbe57c0d41e174ef67fab0d27622 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Mon, 11 Aug 2025 11:26:09 +1000 Subject: [PATCH 2/4] Fix TypeScript errors in tests by adding circuitUuids property Update test files to include the new circuitUuids property in SearchRequestPagination objects: - Fixed use-queue-data-fetching.test.tsx - Fixed reducer.test.ts (2 instances) All TypeScript compilation errors resolved. --- .../__tests__/hooks/use-queue-data-fetching.test.tsx | 3 ++- app/components/queue-control/__tests__/reducer.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx b/app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx index 115538d..2a2780c 100644 --- a/app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx +++ b/app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx @@ -81,7 +81,8 @@ const mockSearchParams: SearchRequestPagination = { hideAttempted: false, hideCompleted: false, showOnlyAttempted: false, - showOnlyCompleted: false + showOnlyCompleted: false, + circuitUuids: [] }; const mockParsedParams: ParsedBoardRouteParameters = { diff --git a/app/components/queue-control/__tests__/reducer.test.ts b/app/components/queue-control/__tests__/reducer.test.ts index 03ad4b9..9eaff6a 100644 --- a/app/components/queue-control/__tests__/reducer.test.ts +++ b/app/components/queue-control/__tests__/reducer.test.ts @@ -47,7 +47,8 @@ const mockSearchParams: SearchRequestPagination = { hideAttempted: false, hideCompleted: false, showOnlyAttempted: false, - showOnlyCompleted: false + showOnlyCompleted: false, + circuitUuids: [] }; const initialState: QueueState = { @@ -292,7 +293,8 @@ describe('queueReducer', () => { hideAttempted: false, hideCompleted: false, showOnlyAttempted: false, - showOnlyCompleted: false + showOnlyCompleted: false, + circuitUuids: [] }; const action: QueueAction = { From 9eb885e68acafd80f8b07f15a27d891a0f8b27ba Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Mon, 11 Aug 2025 11:41:16 +1000 Subject: [PATCH 3/4] Fix useUISearchParams provider error in ClimbCard component Changes: - Made ClimbCard component provider-independent by adding optional onCircuitClick prop - Circuit tags only clickable when callback is provided (in list view) - Added circuit click handler to ClimbsList component with analytics tracking - Circuit tags display but are not clickable in individual climb view (correct behavior) - Resolves 'useUISearchParams must be used within a SearchParamsProvider' error This ensures circuit functionality works properly in both list and individual climb views without requiring all usage contexts to have the UISearchParamsProvider. --- app/components/board-page/climbs-list.tsx | 10 ++++++++++ app/components/climb-card/climb-card.tsx | 15 +++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/components/board-page/climbs-list.tsx b/app/components/board-page/climbs-list.tsx index c07a590..1f63c34 100644 --- a/app/components/board-page/climbs-list.tsx +++ b/app/components/board-page/climbs-list.tsx @@ -6,6 +6,7 @@ import InfiniteScroll from 'react-infinite-scroll-component'; import { track } from '@vercel/analytics'; import { Climb, ParsedBoardRouteParameters, BoardDetails } from '@/app/lib/types'; import { useQueueContext } from '../queue-control/queue-context'; +import { useUISearchParams } from '../queue-control/ui-searchparams-provider'; import ClimbCard from '../climb-card/climb-card'; import { useEffect, useRef } from 'react'; import { PlusCircleOutlined, FireOutlined } from '@ant-design/icons'; @@ -44,6 +45,8 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => { hasDoneFirstFetch, isFetchingClimbs, } = useQueueContext(); + + const { updateFilters } = useUISearchParams(); const searchParams = useSearchParams(); const page = searchParams.get('page'); @@ -161,6 +164,13 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => { climbUuid: climb.uuid, }); }} + onCircuitClick={(circuitUuid) => { + updateFilters({ circuitUuids: [circuitUuid] }); + track('Circuit Tag Clicked', { + circuitUuid, + climbUuid: climb.uuid, + }); + }} /> diff --git a/app/components/climb-card/climb-card.tsx b/app/components/climb-card/climb-card.tsx index c1d6f97..c5a9af0 100644 --- a/app/components/climb-card/climb-card.tsx +++ b/app/components/climb-card/climb-card.tsx @@ -10,7 +10,6 @@ import { Climb, BoardDetails } from '@/app/lib/types'; import ClimbCardActions from './climb-card-actions'; import { useClimbCircuits } from '@/app/hooks/use-climb-circuits'; import { useBoardProvider } from '@/app/components/board-provider/board-provider-context'; -import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider'; type ClimbCardProps = { climb?: Climb; @@ -19,11 +18,11 @@ type ClimbCardProps = { onCoverClick?: () => void; selected?: boolean; actions?: React.JSX.Element[]; + onCircuitClick?: (circuitUuid: string) => void; }; -const ClimbCard = ({ climb, boardDetails, onCoverClick, selected, actions }: ClimbCardProps) => { +const ClimbCard = ({ climb, boardDetails, onCoverClick, selected, actions, onCircuitClick }: ClimbCardProps) => { const { boardName, isAuthenticated } = useBoardProvider(); - const { updateFilters } = useUISearchParams(); const { circuits } = useClimbCircuits(boardName, climb?.uuid || null, Boolean(isAuthenticated && climb?.uuid)); const cover = ; @@ -69,17 +68,17 @@ const ClimbCard = ({ climb, boardDetails, onCoverClick, selected, actions }: Cli key={circuit.uuid} color={circuit.color || undefined} style={{ - cursor: 'pointer', + cursor: onCircuitClick ? 'pointer' : 'default', fontSize: '11px', padding: '2px 6px', margin: '0', borderRadius: '4px' }} - onClick={(e) => { + onClick={onCircuitClick ? (e) => { e.stopPropagation(); - updateFilters({ circuitUuids: [circuit.uuid] }); - }} - title={`Filter by circuit: ${circuit.name}`} + onCircuitClick(circuit.uuid); + } : undefined} + title={onCircuitClick ? `Filter by circuit: ${circuit.name}` : circuit.name || 'Unnamed Circuit'} > {circuit.name || 'Unnamed Circuit'} From 6736737d1ae3e17ec2bdb79b51fb90efe039ab8e Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Mon, 11 Aug 2025 11:49:51 +1000 Subject: [PATCH 4/4] Fix UISearchParamsProvider scope to include ClimbsList component The provider was only wrapping the sidebar (TabsWrapper) but ClimbsList component in the main content area also needs access to useUISearchParams for circuit filtering functionality. Moved UISearchParamsProvider to wrap the entire ListLayoutClient so both the sidebar search forms and the main content ClimbsList can access the search parameters context. This resolves the remaining 'useUISearchParams must be used within a SearchParamsProvider' error. --- .../[set_ids]/[angle]/list/layout-client.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout-client.tsx b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout-client.tsx index 8afbe60..06291a1 100644 --- a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout-client.tsx +++ b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout-client.tsx @@ -69,14 +69,14 @@ const TabsWrapper: React.FC<{ boardDetails: BoardDetails }> = ({ boardDetails }) const ListLayoutClient: React.FC> = ({ boardDetails, children }) => { return ( - - {children} - - + + + {children} + - - - + + + ); };