Skip to content

Commit f6eccd6

Browse files
marcodejonghclaude
andcommitted
Add personal progress filters to climb search
Implements filtering by climbs already attempted or completed by the logged-in user. ## New Features - Hide Attempted: Filter out climbs the user has attempted - Hide Completed: Filter out climbs the user has completed - Only Attempted: Show only climbs the user has attempted - Only Completed: Show only climbs the user has completed ## Implementation Details - Added new boolean properties to SearchRequest type - Enhanced search form UI with toggle switches (only visible when logged in) - Updated backend queries to join ascents/bids tables when filters are active - Modified API route to handle user authentication headers - Updated data fetching to include auth headers when available - Added URL parameter persistence and analytics tracking - Fixed test files to include new required properties ## Database Integration - Uses EXISTS subqueries for optimal performance - Supports both Kilter and Tension board types - Only applies filters when user is authenticated Addresses issue #110 - good first issue for filtering climbs by user progress. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 41a5b29 commit f6eccd6

File tree

9 files changed

+172
-8
lines changed

9 files changed

+172
-8
lines changed

app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/search/route.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,26 @@ export async function GET(
1717
const parsedParams = await parseBoardRouteParamsWithSlugs(params);
1818
const searchParams: SearchRequestPagination = urlParamsToSearchParams(query);
1919

20+
// Extract user authentication from headers for personal progress filters
21+
let userId: number | undefined;
22+
const personalProgressFiltersEnabled =
23+
searchParams.hideAttempted ||
24+
searchParams.hideCompleted ||
25+
searchParams.showOnlyAttempted ||
26+
searchParams.showOnlyCompleted;
27+
28+
if (personalProgressFiltersEnabled) {
29+
const userIdHeader = req.headers.get('x-user-id');
30+
const tokenHeader = req.headers.get('x-auth-token');
31+
32+
// Only use userId if both user ID and token are provided (basic auth check)
33+
if (userIdHeader && tokenHeader && userIdHeader !== 'null') {
34+
userId = parseInt(userIdHeader, 10);
35+
}
36+
}
37+
2038
// Call the separate function to perform the search
21-
const result = await searchClimbs(parsedParams, searchParams);
39+
const result = await searchClimbs(parsedParams, searchParams, userId);
2240

2341
// Return response
2442
return NextResponse.json({

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ const mockSearchParams: SearchRequestPagination = {
7777
onlyClassics: false,
7878
settername: '',
7979
setternameSuggestion: '',
80-
holdsFilter: {}
80+
holdsFilter: {},
81+
hideAttempted: false,
82+
hideCompleted: false,
83+
showOnlyAttempted: false,
84+
showOnlyCompleted: false
8185
};
8286

8387
const mockParsedParams: ParsedBoardRouteParameters = {

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ const mockSearchParams: SearchRequestPagination = {
4343
onlyClassics: false,
4444
settername: '',
4545
setternameSuggestion: '',
46-
holdsFilter: {}
46+
holdsFilter: {},
47+
hideAttempted: false,
48+
hideCompleted: false,
49+
showOnlyAttempted: false,
50+
showOnlyCompleted: false
4751
};
4852

4953
const initialState: QueueState = {
@@ -284,7 +288,11 @@ describe('queueReducer', () => {
284288
onlyClassics: false,
285289
settername: '',
286290
setternameSuggestion: '',
287-
holdsFilter: {}
291+
holdsFilter: {},
292+
hideAttempted: false,
293+
hideCompleted: false,
294+
showOnlyAttempted: false,
295+
showOnlyCompleted: false
288296
};
289297

290298
const action: QueueAction = {

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,18 @@ import { Climb, ParsedBoardRouteParameters, SearchRequestPagination } from '@/ap
77
import { useEffect, useMemo } from 'react';
88
import { useBoardProvider } from '../../board-provider/board-provider-context';
99

10-
const fetcher = (url: string) => fetch(url).then((res) => res.json());
10+
const createFetcher = (authState: { token: string | null; user_id: number | null }) =>
11+
(url: string) => {
12+
const headers: Record<string, string> = {};
13+
14+
// Add authentication headers if available
15+
if (authState.token && authState.user_id) {
16+
headers['x-auth-token'] = authState.token;
17+
headers['x-user-id'] = authState.user_id.toString();
18+
}
19+
20+
return fetch(url, { headers }).then((res) => res.json());
21+
};
1122

1223
interface UseQueueDataFetchingProps {
1324
searchParams: SearchRequestPagination;
@@ -24,8 +35,9 @@ export const useQueueDataFetching = ({
2435
hasDoneFirstFetch,
2536
setHasDoneFirstFetch,
2637
}: UseQueueDataFetchingProps) => {
27-
const { getLogbook } = useBoardProvider();
38+
const { getLogbook, token, user_id } = useBoardProvider();
2839
const fetchedUuidsRef = useRef<string>('');
40+
const fetcher = useMemo(() => createFetcher({ token, user_id }), [token, user_id]);
2941

3042
const getKey = (pageIndex: number, previousPageData: { climbs: Climb[] }) => {
3143
if (previousPageData && previousPageData.climbs.length === 0) return null;

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export const UISearchParamsProvider: React.FC<{ children: React.ReactNode }> = (
3737
if (uiSearchParams.settername) activeFilters.push('setter');
3838
if (uiSearchParams.holdsFilter && Object.entries(uiSearchParams.holdsFilter).length > 0)
3939
activeFilters.push('holds');
40+
if (uiSearchParams.hideAttempted) activeFilters.push('hideAttempted');
41+
if (uiSearchParams.hideCompleted) activeFilters.push('hideCompleted');
42+
if (uiSearchParams.showOnlyAttempted) activeFilters.push('showOnlyAttempted');
43+
if (uiSearchParams.showOnlyCompleted) activeFilters.push('showOnlyCompleted');
4044

4145
if (activeFilters.length > 0) {
4246
track('Climb Search Performed', {

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
'use client';
22

33
import React from 'react';
4-
import { Form, InputNumber, Row, Col, Select, Input } from 'antd';
4+
import { Form, InputNumber, Row, Col, Select, Input, Switch, Divider } from 'antd';
55
import { TENSION_KILTER_GRADES } from '@/app/lib/board-data';
66
import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider';
7+
import { useBoardProvider } from '@/app/components/board-provider/board-provider-context';
78
import SearchClimbNameInput from './search-climb-name-input';
89

910
const BasicSearchForm: React.FC = () => {
1011
const { uiSearchParams, updateFilters } = useUISearchParams();
12+
const { token, user_id } = useBoardProvider();
1113
const grades = TENSION_KILTER_GRADES;
14+
15+
const isLoggedIn = token && user_id;
1216

1317
const handleGradeChange = (type: 'min' | 'max', value: number | undefined) => {
1418
if (type === 'min') {
@@ -139,6 +143,40 @@ const BasicSearchForm: React.FC = () => {
139143
<Form.Item label="Setter Name">
140144
<Input value={uiSearchParams.settername} onChange={(e) => updateFilters({ settername: e.target.value })} />
141145
</Form.Item>
146+
147+
{isLoggedIn && (
148+
<>
149+
<Divider>Personal Progress</Divider>
150+
151+
<Form.Item label="Hide Attempted" tooltip="Hide climbs you have attempted">
152+
<Switch
153+
checked={uiSearchParams.hideAttempted}
154+
onChange={(checked) => updateFilters({ hideAttempted: checked })}
155+
/>
156+
</Form.Item>
157+
158+
<Form.Item label="Hide Completed" tooltip="Hide climbs you have completed">
159+
<Switch
160+
checked={uiSearchParams.hideCompleted}
161+
onChange={(checked) => updateFilters({ hideCompleted: checked })}
162+
/>
163+
</Form.Item>
164+
165+
<Form.Item label="Only Attempted" tooltip="Show only climbs you have attempted">
166+
<Switch
167+
checked={uiSearchParams.showOnlyAttempted}
168+
onChange={(checked) => updateFilters({ showOnlyAttempted: checked })}
169+
/>
170+
</Form.Item>
171+
172+
<Form.Item label="Only Completed" tooltip="Show only climbs you have completed">
173+
<Switch
174+
checked={uiSearchParams.showOnlyCompleted}
175+
onChange={(checked) => updateFilters({ showOnlyCompleted: checked })}
176+
/>
177+
</Form.Item>
178+
</>
179+
)}
142180
</Form>
143181
);
144182
};

app/lib/db/queries/climbs/create-climb-filters.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,57 @@ export const createClimbFilters = (
8989
...notHolds.map((holdId) => notLike(tables.climbs.frames, `%${holdId}r%`)),
9090
];
9191

92+
// Personal progress filter conditions (only apply if userId is provided)
93+
const personalProgressConditions: SQL[] = [];
94+
if (userId) {
95+
const ascentsTable = getTableName(params.board_name, 'ascents');
96+
const bidsTable = getTableName(params.board_name, 'bids');
97+
98+
if (searchParams.hideAttempted) {
99+
personalProgressConditions.push(
100+
sql`NOT EXISTS (
101+
SELECT 1 FROM ${sql.identifier(bidsTable)}
102+
WHERE climb_uuid = ${tables.climbs.uuid}
103+
AND user_id = ${userId}
104+
AND angle = ${params.angle}
105+
)`
106+
);
107+
}
108+
109+
if (searchParams.hideCompleted) {
110+
personalProgressConditions.push(
111+
sql`NOT EXISTS (
112+
SELECT 1 FROM ${sql.identifier(ascentsTable)}
113+
WHERE climb_uuid = ${tables.climbs.uuid}
114+
AND user_id = ${userId}
115+
AND angle = ${params.angle}
116+
)`
117+
);
118+
}
119+
120+
if (searchParams.showOnlyAttempted) {
121+
personalProgressConditions.push(
122+
sql`EXISTS (
123+
SELECT 1 FROM ${sql.identifier(bidsTable)}
124+
WHERE climb_uuid = ${tables.climbs.uuid}
125+
AND user_id = ${userId}
126+
AND angle = ${params.angle}
127+
)`
128+
);
129+
}
130+
131+
if (searchParams.showOnlyCompleted) {
132+
personalProgressConditions.push(
133+
sql`EXISTS (
134+
SELECT 1 FROM ${sql.identifier(ascentsTable)}
135+
WHERE climb_uuid = ${tables.climbs.uuid}
136+
AND user_id = ${userId}
137+
AND angle = ${params.angle}
138+
)`
139+
);
140+
}
141+
}
142+
92143
// User-specific logbook data selectors
93144
const getUserLogbookSelects = () => {
94145
const ascentsTable = getTableName(params.board_name, 'ascents');
@@ -137,7 +188,7 @@ export const createClimbFilters = (
137188

138189
return {
139190
// Helper function to get all climb filtering conditions
140-
getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...holdConditions],
191+
getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...holdConditions, ...personalProgressConditions],
141192

142193
// Size-specific conditions
143194
getSizeConditions: () => sizeConditions,
@@ -169,6 +220,7 @@ export const createClimbFilters = (
169220
nameCondition,
170221
holdConditions,
171222
sizeConditions,
223+
personalProgressConditions,
172224
anyHolds,
173225
notHolds,
174226
};

app/lib/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export type SearchRequest = {
8989
settername: string;
9090
setternameSuggestion: string;
9191
holdsFilter: LitUpHoldsMap;
92+
hideAttempted: boolean;
93+
hideCompleted: boolean;
94+
showOnlyAttempted: boolean;
95+
showOnlyCompleted: boolean;
9296
[key: `hold_${number}`]: HoldFilterValue; // Allow dynamic hold keys directly in the search params
9397
};
9498

app/lib/url-utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export const searchParamsToUrlParams = ({
5050
settername,
5151
setternameSuggestion,
5252
holdsFilter,
53+
hideAttempted,
54+
hideCompleted,
55+
showOnlyAttempted,
56+
showOnlyCompleted,
5357
page,
5458
pageSize,
5559
}: SearchRequestPagination): URLSearchParams => {
@@ -95,6 +99,18 @@ export const searchParamsToUrlParams = ({
9599
if (pageSize !== DEFAULT_SEARCH_PARAMS.pageSize) {
96100
params.pageSize = pageSize.toString();
97101
}
102+
if (hideAttempted !== DEFAULT_SEARCH_PARAMS.hideAttempted) {
103+
params.hideAttempted = hideAttempted.toString();
104+
}
105+
if (hideCompleted !== DEFAULT_SEARCH_PARAMS.hideCompleted) {
106+
params.hideCompleted = hideCompleted.toString();
107+
}
108+
if (showOnlyAttempted !== DEFAULT_SEARCH_PARAMS.showOnlyAttempted) {
109+
params.showOnlyAttempted = showOnlyAttempted.toString();
110+
}
111+
if (showOnlyCompleted !== DEFAULT_SEARCH_PARAMS.showOnlyCompleted) {
112+
params.showOnlyCompleted = showOnlyCompleted.toString();
113+
}
98114

99115
// Add holds filter entries only if they exist
100116
if (holdsFilter && Object.keys(holdsFilter).length > 0) {
@@ -118,6 +134,10 @@ export const DEFAULT_SEARCH_PARAMS: SearchRequestPagination = {
118134
settername: '',
119135
setternameSuggestion: '',
120136
holdsFilter: {},
137+
hideAttempted: false,
138+
hideCompleted: false,
139+
showOnlyAttempted: false,
140+
showOnlyCompleted: false,
121141
page: 0,
122142
pageSize: PAGE_LIMIT,
123143
};
@@ -144,6 +164,10 @@ export const urlParamsToSearchParams = (urlParams: URLSearchParams): SearchReque
144164
setternameSuggestion: urlParams.get('setternameSuggestion') ?? DEFAULT_SEARCH_PARAMS.setternameSuggestion,
145165
//@ts-expect-error fix later
146166
holdsFilter: holdsFilter ?? DEFAULT_SEARCH_PARAMS.holdsFilter,
167+
hideAttempted: urlParams.get('hideAttempted') === 'true',
168+
hideCompleted: urlParams.get('hideCompleted') === 'true',
169+
showOnlyAttempted: urlParams.get('showOnlyAttempted') === 'true',
170+
showOnlyCompleted: urlParams.get('showOnlyCompleted') === 'true',
147171
page: Number(urlParams.get('page') ?? DEFAULT_SEARCH_PARAMS.page),
148172
pageSize: Number(urlParams.get('pageSize') ?? DEFAULT_SEARCH_PARAMS.pageSize),
149173
};

0 commit comments

Comments
 (0)