Skip to content

Commit 8844bb3

Browse files
marcodejonghclaude
andauthored
Fix setter name filter bug and add multiselect autocomplete with route counts (#297)
* Fix setter name filter bug and add multiselect autocomplete with route counts **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 * Remove unused useEffect import from setter-name-select component * Fix module import error - use usePathname to construct API URL instead of non-existent board-details-context * Fix TypeScript errors - update settername from string to string[] across tests and fix setter-stats query * Fix parsedRouteSearchParamsToSearchParams to handle settername string->array conversion from URL query params * Use usePathname hook instead of window.location.pathname for better client-side navigation * Add fallback for usePathname to prevent errors in test environments * Fix React hooks rules violation - call usePathname unconditionally * Add usePathname mock to queue-context tests * Add usePathname mock to all beforeEach blocks in queue-context tests * Fix TypeScript error - add type assertion for settername in parsedRouteSearchParamsToSearchParams * Address code review: use URL utilities, implement lazy loading for setter search - Replace manual pathname regex parsing with constructSetterStatsUrl utility - Add parsedParams to QueueContext for better component access - Implement progressive loading: only fetch when user types 2+ characters - Add search query parameter support to setter-stats API - Use server-side filtering with ILIKE for better performance - Add 50-result limit to setter stats query - Improve UX with helpful placeholder and notFoundContent messages * 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 --------- Co-authored-by: Claude <[email protected]>
1 parent a014b3e commit 8844bb3

File tree

15 files changed

+226
-26
lines changed

15 files changed

+226
-26
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getSetterStats, SetterStat } from '@/app/lib/db/queries/climbs/setter-stats';
2+
import { BoardRouteParameters, ErrorResponse } from '@/app/lib/types';
3+
import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server';
4+
import { NextResponse } from 'next/server';
5+
6+
export async function GET(
7+
req: Request,
8+
props: { params: Promise<BoardRouteParameters> },
9+
): Promise<NextResponse<SetterStat[] | ErrorResponse>> {
10+
const params = await props.params;
11+
12+
try {
13+
const parsedParams = await parseBoardRouteParamsWithSlugs(params);
14+
15+
// Extract search query parameter
16+
const url = new URL(req.url);
17+
const searchQuery = url.searchParams.get('search') || undefined;
18+
19+
const setterStats = await getSetterStats(parsedParams, searchQuery);
20+
21+
return NextResponse.json(setterStats);
22+
} catch (error) {
23+
console.error('Error fetching setter stats:', error);
24+
return NextResponse.json({ error: 'Failed to fetch setter stats' }, { status: 500 });
25+
}
26+
}

app/components/queue-control/__tests__/hooks/use-queue-data-fetching.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const mockSearchParams: SearchRequestPagination = {
7575
sortOrder: 'desc',
7676
name: '',
7777
onlyClassics: false,
78-
settername: '',
78+
settername: [],
7979
setternameSuggestion: '',
8080
holdsFilter: {},
8181
hideAttempted: false,

app/components/queue-control/__tests__/queue-context.test.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import React from 'react';
33
import { render, screen, act, waitFor } from '@testing-library/react';
44
import '@testing-library/jest-dom';
5-
import { useSearchParams, useRouter } from 'next/navigation';
5+
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
66
import { QueueProvider, useQueueContext } from '../queue-context';
77
import { ParsedBoardRouteParameters, Climb } from '@/app/lib/types';
88
import { ClimbQueueItem } from '../types';
@@ -11,7 +11,8 @@ import { useConnection } from '../../connection-manager/use-connection';
1111
// Mock dependencies
1212
vi.mock('next/navigation', () => ({
1313
useSearchParams: vi.fn(),
14-
useRouter: vi.fn()
14+
useRouter: vi.fn(),
15+
usePathname: vi.fn()
1516
}));
1617

1718
vi.mock('@/app/lib/url-utils', () => ({
@@ -76,6 +77,7 @@ const mockSearchParams = new URLSearchParams();
7677
const mockRouter = {
7778
replace: vi.fn()
7879
};
80+
const mockPathname = '/test/path';
7981

8082
const mockUseConnection = vi.mocked(useConnection);
8183

@@ -144,6 +146,7 @@ describe('QueueProvider', () => {
144146
vi.clearAllMocks();
145147
(useSearchParams as ReturnType<typeof vi.fn>).mockReturnValue(mockSearchParams);
146148
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue(mockRouter);
149+
(usePathname as ReturnType<typeof vi.fn>).mockReturnValue(mockPathname);
147150
mockUseConnection.mockReturnValue({
148151
sendData: vi.fn(),
149152
peerId: 'test-peer-id',
@@ -249,7 +252,8 @@ describe('QueueProvider with peer functionality', () => {
249252
vi.clearAllMocks();
250253
(useSearchParams as ReturnType<typeof vi.fn>).mockReturnValue(mockSearchParams);
251254
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue(mockRouter);
252-
255+
(usePathname as ReturnType<typeof vi.fn>).mockReturnValue(mockPathname);
256+
253257
// Mock peer context with host
254258
mockSubscribeToData.mockReturnValue(vi.fn()); // Return unsubscribe function
255259
mockUseConnection.mockReturnValue({
@@ -364,6 +368,7 @@ describe('QueueProvider utility functions', () => {
364368
vi.clearAllMocks();
365369
(useSearchParams as ReturnType<typeof vi.fn>).mockReturnValue(mockSearchParams);
366370
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue(mockRouter);
371+
(usePathname as ReturnType<typeof vi.fn>).mockReturnValue(mockPathname);
367372
mockUseConnection.mockReturnValue({
368373
sendData: vi.fn(),
369374
peerId: 'test-peer-id',

app/components/queue-control/__tests__/reducer.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const mockSearchParams: SearchRequestPagination = {
4141
sortOrder: 'desc',
4242
name: '',
4343
onlyClassics: false,
44-
settername: '',
44+
settername: [],
4545
setternameSuggestion: '',
4646
holdsFilter: {},
4747
hideAttempted: false,
@@ -286,7 +286,7 @@ describe('queueReducer', () => {
286286
sortOrder: 'asc',
287287
name: '',
288288
onlyClassics: false,
289-
settername: '',
289+
settername: [],
290290
setternameSuggestion: '',
291291
holdsFilter: {},
292292
hideAttempted: false,

app/components/queue-control/queue-context.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
'use client';
33

44
import React, { useContext, createContext, ReactNode, useEffect, useCallback } from 'react';
5-
import { useSearchParams, useRouter } from 'next/navigation';
5+
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
66
import { v4 as uuidv4 } from 'uuid';
77
import { useConnection } from '../connection-manager/use-connection';
88
import { useQueueReducer } from './reducer';
@@ -29,10 +29,11 @@ const QueueContext = createContext<QueueContextType | undefined>(undefined);
2929
export const QueueProvider = ({ parsedParams, children }: QueueContextProps) => {
3030
const searchParams = useSearchParams();
3131
const router = useRouter();
32+
const pathname = usePathname();
3233
const initialSearchParams = urlParamsToSearchParams(searchParams);
3334
const [state, dispatch] = useQueueReducer(initialSearchParams);
3435
const connection = useConnection();
35-
36+
3637
// Check if we're in controller mode
3738
const controllerUrl = searchParams.get('controllerUrl');
3839
const isControllerMode = !!controllerUrl;
@@ -171,6 +172,7 @@ export const QueueProvider = ({ parsedParams, children }: QueueContextProps) =>
171172
isFetchingClimbs,
172173
hasDoneFirstFetch: state.hasDoneFirstFetch,
173174
viewOnlyMode: isControllerMode ? false : (hostId ? !state.initialQueueDataReceivedFromPeers : false),
175+
parsedParams,
174176
// Actions
175177
addToQueue: (climb: Climb) => {
176178
const newItem = createClimbQueueItem(climb, peerId);
@@ -246,8 +248,9 @@ export const QueueProvider = ({ parsedParams, children }: QueueContextProps) =>
246248

247249
// Update URL with new search parameters
248250
const urlParams = searchParamsToUrlParams(params);
249-
const currentPath = window.location.pathname;
250-
router.replace(`${currentPath}?${urlParams.toString()}`);
251+
const queryString = urlParams.toString();
252+
const newUrl = queryString ? `${pathname}?${queryString}` : pathname;
253+
router.replace(newUrl, { scroll: false });
251254
},
252255

253256
mirrorClimb: () => {

app/components/queue-control/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Climb, SearchRequestPagination } from '@/app/lib/types';
1+
import { Climb, SearchRequestPagination, ParsedBoardRouteParameters } from '@/app/lib/types';
22

33
export type PeerId = string | null;
44
export type UserName = PeerId;
@@ -51,6 +51,7 @@ export interface QueueContextType {
5151
isFetchingClimbs: boolean;
5252
hasDoneFirstFetch: boolean;
5353
viewOnlyMode: boolean;
54+
parsedParams: ParsedBoardRouteParameters;
5455
addToQueue: (climb: Climb) => void;
5556
removeFromQueue: (item: ClimbQueueItem) => void;
5657
setCurrentClimb: (climb: Climb) => void;

app/components/queue-control/ui-searchparams-provider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const UISearchParamsProvider: React.FC<{ children: React.ReactNode }> = (
3434
if (uiSearchParams.minRating) activeFilters.push('minRating');
3535
if (uiSearchParams.onlyClassics) activeFilters.push('classics');
3636
if (uiSearchParams.gradeAccuracy) activeFilters.push('gradeAccuracy');
37-
if (uiSearchParams.settername) activeFilters.push('setter');
37+
if (uiSearchParams.settername.length > 0) activeFilters.push('setter');
3838
if (uiSearchParams.holdsFilter && Object.entries(uiSearchParams.holdsFilter).length > 0)
3939
activeFilters.push('holds');
4040
if (uiSearchParams.hideAttempted) activeFilters.push('hideAttempted');

app/components/search-drawer/basic-search-form.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use client';
22

33
import React from 'react';
4-
import { Form, InputNumber, Row, Col, Select, Input, Switch, Alert, Typography } from 'antd';
4+
import { Form, InputNumber, Row, Col, Select, Switch, Alert, Typography } from 'antd';
55
import { TENSION_KILTER_GRADES } from '@/app/lib/board-data';
66
import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider';
77
import { useBoardProvider } from '@/app/components/board-provider/board-provider-context';
88
import SearchClimbNameInput from './search-climb-name-input';
9+
import SetterNameSelect from './setter-name-select';
910

1011
const { Title } = Typography;
1112

@@ -191,7 +192,7 @@ const BasicSearchForm: React.FC = () => {
191192
</Form.Item>
192193

193194
<Form.Item label="Setter Name">
194-
<Input value={uiSearchParams.settername} onChange={(e) => updateFilters({ settername: e.target.value })} />
195+
<SetterNameSelect />
195196
</Form.Item>
196197

197198
<Form.Item>

app/components/search-drawer/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const defaultClimbSearchParameters: SearchRequestPagination = {
1212
minRating: 1.0,
1313
onlyClassics: false,
1414
gradeAccuracy: 1,
15-
settername: '',
15+
settername: [],
1616
setternameSuggestion: '',
1717
//@ts-expect-error TODO fix later
1818
holdsFilter: '',
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use client';
2+
3+
import React, { useState } from 'react';
4+
import { Select } from 'antd';
5+
import { useUISearchParams } from '../queue-control/ui-searchparams-provider';
6+
import { useQueueContext } from '../queue-control/queue-context';
7+
import useSWR from 'swr';
8+
import { constructSetterStatsUrl } from '@/app/lib/url-utils';
9+
10+
interface SetterStat {
11+
setter_username: string;
12+
climb_count: number;
13+
}
14+
15+
const fetcher = (url: string) => fetch(url).then((res) => res.json());
16+
17+
const MIN_SEARCH_LENGTH = 2; // Only search when user has typed at least 2 characters
18+
19+
const SetterNameSelect = () => {
20+
const { uiSearchParams, updateFilters } = useUISearchParams();
21+
const { parsedParams } = useQueueContext();
22+
const [searchValue, setSearchValue] = useState('');
23+
const [isOpen, setIsOpen] = useState(false);
24+
25+
// Fetch top setters when dropdown is open OR when user is searching
26+
const shouldFetch = isOpen || searchValue.length >= MIN_SEARCH_LENGTH;
27+
const isSearching = searchValue.length >= MIN_SEARCH_LENGTH;
28+
29+
// Build API URL - with search query if searching, without if just showing top setters
30+
const apiUrl = shouldFetch
31+
? constructSetterStatsUrl(parsedParams, isSearching ? searchValue : undefined)
32+
: null;
33+
34+
// Fetch setter stats from the API
35+
const { data: setterStats, isLoading } = useSWR<SetterStat[]>(
36+
apiUrl,
37+
fetcher,
38+
{
39+
revalidateOnFocus: false,
40+
revalidateOnReconnect: false,
41+
keepPreviousData: true,
42+
}
43+
);
44+
45+
// Map setter stats to Select options
46+
const options = React.useMemo(() => {
47+
if (!setterStats) return [];
48+
49+
return setterStats.map(stat => ({
50+
value: stat.setter_username,
51+
label: `${stat.setter_username} (${stat.climb_count})`,
52+
count: stat.climb_count,
53+
}));
54+
}, [setterStats]);
55+
56+
return (
57+
<Select
58+
mode="multiple"
59+
placeholder="Select setters..."
60+
value={uiSearchParams.settername}
61+
onChange={(value) => updateFilters({ settername: value })}
62+
onSearch={setSearchValue}
63+
onDropdownVisibleChange={setIsOpen}
64+
loading={isLoading}
65+
showSearch
66+
filterOption={false} // Server-side filtering
67+
options={options}
68+
style={{ width: '100%' }}
69+
maxTagCount="responsive"
70+
notFoundContent={
71+
isLoading
72+
? 'Loading...'
73+
: !isOpen && searchValue.length === 0
74+
? 'Open dropdown to see setters'
75+
: 'No setters found'
76+
}
77+
/>
78+
);
79+
};
80+
81+
export default SetterNameSelect;

0 commit comments

Comments
 (0)