From f7112f9b6adb5dbc2119270248e009861891ff08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 03:28:18 +0000 Subject: [PATCH 01/13] Fix setter name filter bug and add multiselect autocomplete with route counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Bug Fix:** - Fixed setter name filter not actually filtering climbs - The parameter was flowing through UI → URL → API but never used in SQL WHERE clause - Added setterNameCondition to create-climb-filters.ts using inArray() **Enhancements:** - Changed setter filter from single text input to multiselect autocomplete - Added route count display next to each setter name (e.g., "John Doe (42)") - Created new API endpoint: /api/v1/[board]/[layout]/[size]/[sets]/[angle]/setters - Uses AntD Select component with mode="multiple" for better UX **Type Changes:** - Updated SearchRequest.settername from string to string[] - Updated URL parameter handling to support comma-separated values - Fixed analytics tracking to check array length **Technical Details:** - New query: app/lib/db/queries/climbs/setter-stats.ts - New component: app/components/search-drawer/setter-name-select.tsx - Uses SWR for data fetching with client-side search filtering - Maintains AntD design system best practices --- .../[set_ids]/[angle]/setters/route.ts | 21 +++++++ .../ui-searchparams-provider.tsx | 2 +- .../search-drawer/basic-search-form.tsx | 5 +- .../search-drawer/setter-name-select.tsx | 62 +++++++++++++++++++ .../db/queries/climbs/create-climb-filters.ts | 10 ++- app/lib/db/queries/climbs/setter-stats.ts | 39 ++++++++++++ app/lib/types.ts | 2 +- app/lib/url-utils.ts | 8 +-- 8 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/setters/route.ts create mode 100644 app/components/search-drawer/setter-name-select.tsx create mode 100644 app/lib/db/queries/climbs/setter-stats.ts diff --git a/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/setters/route.ts b/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/setters/route.ts new file mode 100644 index 00000000..dd1f2d6b --- /dev/null +++ b/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/setters/route.ts @@ -0,0 +1,21 @@ +import { getSetterStats, SetterStat } from '@/app/lib/db/queries/climbs/setter-stats'; +import { BoardRouteParameters, ErrorResponse } from '@/app/lib/types'; +import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; +import { NextResponse } from 'next/server'; + +export async function GET( + req: Request, + props: { params: Promise }, +): Promise> { + const params = await props.params; + + try { + const parsedParams = await parseBoardRouteParamsWithSlugs(params); + const setterStats = await getSetterStats(parsedParams); + + return NextResponse.json(setterStats); + } catch (error) { + console.error('Error fetching setter stats:', error); + return NextResponse.json({ error: 'Failed to fetch setter stats' }, { status: 500 }); + } +} diff --git a/app/components/queue-control/ui-searchparams-provider.tsx b/app/components/queue-control/ui-searchparams-provider.tsx index 42c25035..9b4d2ef0 100644 --- a/app/components/queue-control/ui-searchparams-provider.tsx +++ b/app/components/queue-control/ui-searchparams-provider.tsx @@ -34,7 +34,7 @@ export const UISearchParamsProvider: React.FC<{ children: React.ReactNode }> = ( if (uiSearchParams.minRating) activeFilters.push('minRating'); if (uiSearchParams.onlyClassics) activeFilters.push('classics'); if (uiSearchParams.gradeAccuracy) activeFilters.push('gradeAccuracy'); - if (uiSearchParams.settername) activeFilters.push('setter'); + if (uiSearchParams.settername.length > 0) activeFilters.push('setter'); if (uiSearchParams.holdsFilter && Object.entries(uiSearchParams.holdsFilter).length > 0) activeFilters.push('holds'); if (uiSearchParams.hideAttempted) activeFilters.push('hideAttempted'); diff --git a/app/components/search-drawer/basic-search-form.tsx b/app/components/search-drawer/basic-search-form.tsx index c802b794..e6481350 100644 --- a/app/components/search-drawer/basic-search-form.tsx +++ b/app/components/search-drawer/basic-search-form.tsx @@ -1,11 +1,12 @@ 'use client'; import React from 'react'; -import { Form, InputNumber, Row, Col, Select, Input, Switch, Alert, Typography } from 'antd'; +import { Form, InputNumber, Row, Col, Select, Switch, Alert, Typography } from 'antd'; 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 SearchClimbNameInput from './search-climb-name-input'; +import SetterNameSelect from './setter-name-select'; const { Title } = Typography; @@ -191,7 +192,7 @@ const BasicSearchForm: React.FC = () => { - updateFilters({ settername: e.target.value })} /> + diff --git a/app/components/search-drawer/setter-name-select.tsx b/app/components/search-drawer/setter-name-select.tsx new file mode 100644 index 00000000..32b69986 --- /dev/null +++ b/app/components/search-drawer/setter-name-select.tsx @@ -0,0 +1,62 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { Select } from 'antd'; +import { useUISearchParams } from '../queue-control/ui-searchparams-provider'; +import useSWR from 'swr'; +import { useBoardDetailsContext } from '@/app/components/board-page/board-details-context'; + +interface SetterStat { + setter_username: string; + climb_count: number; +} + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +const SetterNameSelect = () => { + const { uiSearchParams, updateFilters } = useUISearchParams(); + const { boardUrl } = useBoardDetailsContext(); + const [searchValue, setSearchValue] = useState(''); + + // Fetch setter stats from the API + const { data: setterStats, isLoading } = useSWR( + boardUrl ? `${boardUrl}/setters` : null, + fetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + // Filter options based on search value + const filteredOptions = React.useMemo(() => { + if (!setterStats) return []; + + const lowerSearch = searchValue.toLowerCase(); + return setterStats + .filter(stat => stat.setter_username.toLowerCase().includes(lowerSearch)) + .map(stat => ({ + value: stat.setter_username, + label: `${stat.setter_username} (${stat.climb_count})`, + count: stat.climb_count, + })); + }, [setterStats, searchValue]); + + return ( + updateFilters({ settername: value })} onSearch={setSearchValue} loading={isLoading} showSearch - filterOption={false} // We handle filtering manually - options={filteredOptions} + filterOption={false} // Server-side filtering + options={options} style={{ width: '100%' }} maxTagCount="responsive" + notFoundContent={ + searchValue.length < MIN_SEARCH_LENGTH + ? `Type at least ${MIN_SEARCH_LENGTH} characters to search` + : isLoading + ? 'Loading...' + : 'No setters found' + } /> ); }; diff --git a/app/lib/db/queries/climbs/setter-stats.ts b/app/lib/db/queries/climbs/setter-stats.ts index 80b0124d..97196239 100644 --- a/app/lib/db/queries/climbs/setter-stats.ts +++ b/app/lib/db/queries/climbs/setter-stats.ts @@ -1,4 +1,4 @@ -import { eq, sql, and } from 'drizzle-orm'; +import { eq, sql, and, ilike } from 'drizzle-orm'; import { dbz as db } from '@/app/lib/db/db'; import { ParsedBoardRouteParameters } from '@/app/lib/types'; import { getBoardTables } from '@/lib/db/queries/util/table-select'; @@ -10,10 +10,28 @@ export interface SetterStat { export const getSetterStats = async ( params: ParsedBoardRouteParameters, + searchQuery?: string, ): Promise => { const tables = getBoardTables(params.board_name); try { + // Build WHERE conditions + const whereConditions = [ + eq(tables.climbs.layoutId, params.layout_id), + eq(tables.climbStats.angle, params.angle), + sql`${tables.climbs.edgeLeft} > ${tables.productSizes.edgeLeft}`, + sql`${tables.climbs.edgeRight} < ${tables.productSizes.edgeRight}`, + sql`${tables.climbs.edgeBottom} > ${tables.productSizes.edgeBottom}`, + sql`${tables.climbs.edgeTop} < ${tables.productSizes.edgeTop}`, + sql`${tables.climbs.setterUsername} IS NOT NULL`, + sql`${tables.climbs.setterUsername} != ''`, + ]; + + // Add search filter if provided + if (searchQuery && searchQuery.trim().length > 0) { + whereConditions.push(ilike(tables.climbs.setterUsername, `%${searchQuery}%`)); + } + const result = await db .select({ setter_username: tables.climbs.setterUsername, @@ -22,20 +40,10 @@ export const getSetterStats = async ( .from(tables.climbs) .innerJoin(tables.climbStats, sql`${tables.climbStats.climbUuid} = ${tables.climbs.uuid}`) .innerJoin(tables.productSizes, eq(tables.productSizes.id, params.size_id)) - .where( - and( - eq(tables.climbs.layoutId, params.layout_id), - eq(tables.climbStats.angle, params.angle), - sql`${tables.climbs.edgeLeft} > ${tables.productSizes.edgeLeft}`, - sql`${tables.climbs.edgeRight} < ${tables.productSizes.edgeRight}`, - sql`${tables.climbs.edgeBottom} > ${tables.productSizes.edgeBottom}`, - sql`${tables.climbs.edgeTop} < ${tables.productSizes.edgeTop}`, - sql`${tables.climbs.setterUsername} IS NOT NULL`, - sql`${tables.climbs.setterUsername} != ''`, - ) - ) + .where(and(...whereConditions)) .groupBy(tables.climbs.setterUsername) - .orderBy(sql`count(*) DESC`); + .orderBy(sql`count(*) DESC`) + .limit(50); // Limit results for performance // Filter out any nulls that might have slipped through return result.filter((stat): stat is SetterStat => stat.setter_username !== null); diff --git a/app/lib/url-utils.ts b/app/lib/url-utils.ts index b520a52a..532790c5 100644 --- a/app/lib/url-utils.ts +++ b/app/lib/url-utils.ts @@ -258,6 +258,14 @@ export const constructClimbSearchUrl = ( queryString: string, ) => `/api/v1/${board_name}/${layout_id}/${size_id}/${set_ids}/${angle}/search?${queryString}`; +export const constructSetterStatsUrl = ( + { board_name, layout_id, angle, size_id, set_ids }: ParsedBoardRouteParameters, + searchQuery?: string, +) => { + const baseUrl = `/api/v1/${board_name}/${layout_id}/${size_id}/${set_ids}/${angle}/setters`; + return searchQuery ? `${baseUrl}?search=${encodeURIComponent(searchQuery)}` : baseUrl; +}; + // New slug-based URL construction functions export const constructClimbListWithSlugs = ( board_name: string, From 06a32b79ea677cad19a6f138259674ada624000e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 06:09:10 +0000 Subject: [PATCH 13/13] Show top setters when dropdown is opened without typing - Track dropdown open state with onDropdownVisibleChange - Fetch top setters when dropdown opens (before user types) - Switch to search mode when user types 2+ characters - Improves UX by showing popular setters immediately --- .../search-drawer/setter-name-select.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/components/search-drawer/setter-name-select.tsx b/app/components/search-drawer/setter-name-select.tsx index 58af2378..4c891d25 100644 --- a/app/components/search-drawer/setter-name-select.tsx +++ b/app/components/search-drawer/setter-name-select.tsx @@ -14,20 +14,24 @@ interface SetterStat { const fetcher = (url: string) => fetch(url).then((res) => res.json()); -const MIN_SEARCH_LENGTH = 2; // Only fetch when user has typed at least 2 characters +const MIN_SEARCH_LENGTH = 2; // Only search when user has typed at least 2 characters const SetterNameSelect = () => { const { uiSearchParams, updateFilters } = useUISearchParams(); const { parsedParams } = useQueueContext(); const [searchValue, setSearchValue] = useState(''); + const [isOpen, setIsOpen] = useState(false); - // Only fetch when search value is long enough (lazy loading) - const shouldFetch = searchValue.length >= MIN_SEARCH_LENGTH; + // Fetch top setters when dropdown is open OR when user is searching + const shouldFetch = isOpen || searchValue.length >= MIN_SEARCH_LENGTH; + const isSearching = searchValue.length >= MIN_SEARCH_LENGTH; + + // Build API URL - with search query if searching, without if just showing top setters const apiUrl = shouldFetch - ? constructSetterStatsUrl(parsedParams, searchValue) + ? constructSetterStatsUrl(parsedParams, isSearching ? searchValue : undefined) : null; - // Fetch setter stats from the API (only when shouldFetch is true) + // Fetch setter stats from the API const { data: setterStats, isLoading } = useSWR( apiUrl, fetcher, @@ -52,10 +56,11 @@ const SetterNameSelect = () => { return (