From f6eccd648204b3e0dd604dafe86265f9c1b95ab8 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sun, 10 Aug 2025 21:50:02 +1000 Subject: [PATCH 1/2] Add personal progress filters to climb search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../[set_ids]/[angle]/search/route.ts | 20 ++++++- .../hooks/use-queue-data-fetching.test.tsx | 6 ++- .../queue-control/__tests__/reducer.test.ts | 12 ++++- .../hooks/use-queue-data-fetching.tsx | 16 +++++- .../ui-searchparams-provider.tsx | 4 ++ .../search-drawer/basic-search-form.tsx | 40 +++++++++++++- .../db/queries/climbs/create-climb-filters.ts | 54 ++++++++++++++++++- app/lib/types.ts | 4 ++ app/lib/url-utils.ts | 24 +++++++++ 9 files changed, 172 insertions(+), 8 deletions(-) diff --git a/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/search/route.ts b/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/search/route.ts index 6e1022ec..cee668e1 100644 --- a/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/search/route.ts +++ b/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/search/route.ts @@ -17,8 +17,26 @@ export async function GET( const parsedParams = await parseBoardRouteParamsWithSlugs(params); const searchParams: SearchRequestPagination = urlParamsToSearchParams(query); + // Extract user authentication from headers for personal progress filters + let userId: number | undefined; + const personalProgressFiltersEnabled = + searchParams.hideAttempted || + searchParams.hideCompleted || + searchParams.showOnlyAttempted || + searchParams.showOnlyCompleted; + + if (personalProgressFiltersEnabled) { + const userIdHeader = req.headers.get('x-user-id'); + const tokenHeader = req.headers.get('x-auth-token'); + + // Only use userId if both user ID and token are provided (basic auth check) + if (userIdHeader && tokenHeader && userIdHeader !== 'null') { + userId = parseInt(userIdHeader, 10); + } + } + // Call the separate function to perform the search - const result = await searchClimbs(parsedParams, searchParams); + const result = await searchClimbs(parsedParams, searchParams, userId); // Return response return NextResponse.json({ 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 9aad8377..115538dc 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 @@ -77,7 +77,11 @@ const mockSearchParams: SearchRequestPagination = { onlyClassics: false, settername: '', setternameSuggestion: '', - holdsFilter: {} + holdsFilter: {}, + hideAttempted: false, + hideCompleted: false, + showOnlyAttempted: false, + showOnlyCompleted: false }; const mockParsedParams: ParsedBoardRouteParameters = { diff --git a/app/components/queue-control/__tests__/reducer.test.ts b/app/components/queue-control/__tests__/reducer.test.ts index 5d097025..03ad4b92 100644 --- a/app/components/queue-control/__tests__/reducer.test.ts +++ b/app/components/queue-control/__tests__/reducer.test.ts @@ -43,7 +43,11 @@ const mockSearchParams: SearchRequestPagination = { onlyClassics: false, settername: '', setternameSuggestion: '', - holdsFilter: {} + holdsFilter: {}, + hideAttempted: false, + hideCompleted: false, + showOnlyAttempted: false, + showOnlyCompleted: false }; const initialState: QueueState = { @@ -284,7 +288,11 @@ describe('queueReducer', () => { onlyClassics: false, settername: '', setternameSuggestion: '', - holdsFilter: {} + holdsFilter: {}, + hideAttempted: false, + hideCompleted: false, + showOnlyAttempted: false, + showOnlyCompleted: false }; const action: QueueAction = { diff --git a/app/components/queue-control/hooks/use-queue-data-fetching.tsx b/app/components/queue-control/hooks/use-queue-data-fetching.tsx index 1f0a9c4b..568053c0 100644 --- a/app/components/queue-control/hooks/use-queue-data-fetching.tsx +++ b/app/components/queue-control/hooks/use-queue-data-fetching.tsx @@ -7,7 +7,18 @@ import { Climb, ParsedBoardRouteParameters, SearchRequestPagination } from '@/ap import { useEffect, useMemo } from 'react'; import { useBoardProvider } from '../../board-provider/board-provider-context'; -const fetcher = (url: string) => fetch(url).then((res) => res.json()); +const createFetcher = (authState: { token: string | null; user_id: number | null }) => + (url: string) => { + const headers: Record = {}; + + // Add authentication headers if available + if (authState.token && authState.user_id) { + headers['x-auth-token'] = authState.token; + headers['x-user-id'] = authState.user_id.toString(); + } + + return fetch(url, { headers }).then((res) => res.json()); + }; interface UseQueueDataFetchingProps { searchParams: SearchRequestPagination; @@ -24,8 +35,9 @@ export const useQueueDataFetching = ({ hasDoneFirstFetch, setHasDoneFirstFetch, }: UseQueueDataFetchingProps) => { - const { getLogbook } = useBoardProvider(); + const { getLogbook, token, user_id } = useBoardProvider(); const fetchedUuidsRef = useRef(''); + const fetcher = useMemo(() => createFetcher({ token, user_id }), [token, user_id]); const getKey = (pageIndex: number, previousPageData: { climbs: Climb[] }) => { if (previousPageData && previousPageData.climbs.length === 0) return null; diff --git a/app/components/queue-control/ui-searchparams-provider.tsx b/app/components/queue-control/ui-searchparams-provider.tsx index d2bc6e67..42c25035 100644 --- a/app/components/queue-control/ui-searchparams-provider.tsx +++ b/app/components/queue-control/ui-searchparams-provider.tsx @@ -37,6 +37,10 @@ export const UISearchParamsProvider: React.FC<{ children: React.ReactNode }> = ( if (uiSearchParams.settername) activeFilters.push('setter'); if (uiSearchParams.holdsFilter && Object.entries(uiSearchParams.holdsFilter).length > 0) activeFilters.push('holds'); + if (uiSearchParams.hideAttempted) activeFilters.push('hideAttempted'); + if (uiSearchParams.hideCompleted) activeFilters.push('hideCompleted'); + if (uiSearchParams.showOnlyAttempted) activeFilters.push('showOnlyAttempted'); + if (uiSearchParams.showOnlyCompleted) activeFilters.push('showOnlyCompleted'); 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 7a7d6075..d4bdb04b 100644 --- a/app/components/search-drawer/basic-search-form.tsx +++ b/app/components/search-drawer/basic-search-form.tsx @@ -1,14 +1,18 @@ 'use client'; import React from 'react'; -import { Form, InputNumber, Row, Col, Select, Input } from 'antd'; +import { Form, InputNumber, Row, Col, Select, Input, Switch, Divider } 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'; const BasicSearchForm: React.FC = () => { const { uiSearchParams, updateFilters } = useUISearchParams(); + const { token, user_id } = useBoardProvider(); const grades = TENSION_KILTER_GRADES; + + const isLoggedIn = token && user_id; const handleGradeChange = (type: 'min' | 'max', value: number | undefined) => { if (type === 'min') { @@ -139,6 +143,40 @@ const BasicSearchForm: React.FC = () => { updateFilters({ settername: e.target.value })} /> + + {isLoggedIn && ( + <> + Personal Progress + + + updateFilters({ hideAttempted: checked })} + /> + + + + updateFilters({ hideCompleted: checked })} + /> + + + + updateFilters({ showOnlyAttempted: checked })} + /> + + + + updateFilters({ showOnlyCompleted: checked })} + /> + + + )} ); }; diff --git a/app/lib/db/queries/climbs/create-climb-filters.ts b/app/lib/db/queries/climbs/create-climb-filters.ts index d47ca4e1..87ce9c46 100644 --- a/app/lib/db/queries/climbs/create-climb-filters.ts +++ b/app/lib/db/queries/climbs/create-climb-filters.ts @@ -89,6 +89,57 @@ export const createClimbFilters = ( ...notHolds.map((holdId) => notLike(tables.climbs.frames, `%${holdId}r%`)), ]; + // Personal progress filter conditions (only apply if userId is provided) + const personalProgressConditions: SQL[] = []; + if (userId) { + const ascentsTable = getTableName(params.board_name, 'ascents'); + const bidsTable = getTableName(params.board_name, 'bids'); + + if (searchParams.hideAttempted) { + personalProgressConditions.push( + sql`NOT EXISTS ( + SELECT 1 FROM ${sql.identifier(bidsTable)} + WHERE climb_uuid = ${tables.climbs.uuid} + AND user_id = ${userId} + AND angle = ${params.angle} + )` + ); + } + + if (searchParams.hideCompleted) { + personalProgressConditions.push( + sql`NOT EXISTS ( + SELECT 1 FROM ${sql.identifier(ascentsTable)} + WHERE climb_uuid = ${tables.climbs.uuid} + AND user_id = ${userId} + AND angle = ${params.angle} + )` + ); + } + + if (searchParams.showOnlyAttempted) { + personalProgressConditions.push( + sql`EXISTS ( + SELECT 1 FROM ${sql.identifier(bidsTable)} + WHERE climb_uuid = ${tables.climbs.uuid} + AND user_id = ${userId} + AND angle = ${params.angle} + )` + ); + } + + if (searchParams.showOnlyCompleted) { + personalProgressConditions.push( + sql`EXISTS ( + SELECT 1 FROM ${sql.identifier(ascentsTable)} + WHERE climb_uuid = ${tables.climbs.uuid} + AND user_id = ${userId} + AND angle = ${params.angle} + )` + ); + } + } + // User-specific logbook data selectors const getUserLogbookSelects = () => { const ascentsTable = getTableName(params.board_name, 'ascents'); @@ -137,7 +188,7 @@ export const createClimbFilters = ( return { // Helper function to get all climb filtering conditions - getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...holdConditions], + getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...holdConditions, ...personalProgressConditions], // Size-specific conditions getSizeConditions: () => sizeConditions, @@ -169,6 +220,7 @@ export const createClimbFilters = ( nameCondition, holdConditions, sizeConditions, + personalProgressConditions, anyHolds, notHolds, }; diff --git a/app/lib/types.ts b/app/lib/types.ts index bf26afbf..f3671ff1 100644 --- a/app/lib/types.ts +++ b/app/lib/types.ts @@ -89,6 +89,10 @@ export type SearchRequest = { settername: string; setternameSuggestion: string; holdsFilter: LitUpHoldsMap; + hideAttempted: boolean; + hideCompleted: boolean; + showOnlyAttempted: boolean; + showOnlyCompleted: boolean; [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 ba8dba64..ab457f0a 100644 --- a/app/lib/url-utils.ts +++ b/app/lib/url-utils.ts @@ -50,6 +50,10 @@ export const searchParamsToUrlParams = ({ settername, setternameSuggestion, holdsFilter, + hideAttempted, + hideCompleted, + showOnlyAttempted, + showOnlyCompleted, page, pageSize, }: SearchRequestPagination): URLSearchParams => { @@ -95,6 +99,18 @@ export const searchParamsToUrlParams = ({ if (pageSize !== DEFAULT_SEARCH_PARAMS.pageSize) { params.pageSize = pageSize.toString(); } + if (hideAttempted !== DEFAULT_SEARCH_PARAMS.hideAttempted) { + params.hideAttempted = hideAttempted.toString(); + } + if (hideCompleted !== DEFAULT_SEARCH_PARAMS.hideCompleted) { + params.hideCompleted = hideCompleted.toString(); + } + if (showOnlyAttempted !== DEFAULT_SEARCH_PARAMS.showOnlyAttempted) { + params.showOnlyAttempted = showOnlyAttempted.toString(); + } + if (showOnlyCompleted !== DEFAULT_SEARCH_PARAMS.showOnlyCompleted) { + params.showOnlyCompleted = showOnlyCompleted.toString(); + } // Add holds filter entries only if they exist if (holdsFilter && Object.keys(holdsFilter).length > 0) { @@ -118,6 +134,10 @@ export const DEFAULT_SEARCH_PARAMS: SearchRequestPagination = { settername: '', setternameSuggestion: '', holdsFilter: {}, + hideAttempted: false, + hideCompleted: false, + showOnlyAttempted: false, + showOnlyCompleted: false, page: 0, pageSize: PAGE_LIMIT, }; @@ -144,6 +164,10 @@ export const urlParamsToSearchParams = (urlParams: URLSearchParams): SearchReque setternameSuggestion: urlParams.get('setternameSuggestion') ?? DEFAULT_SEARCH_PARAMS.setternameSuggestion, //@ts-expect-error fix later holdsFilter: holdsFilter ?? DEFAULT_SEARCH_PARAMS.holdsFilter, + hideAttempted: urlParams.get('hideAttempted') === 'true', + hideCompleted: urlParams.get('hideCompleted') === 'true', + showOnlyAttempted: urlParams.get('showOnlyAttempted') === 'true', + showOnlyCompleted: urlParams.get('showOnlyCompleted') === 'true', page: Number(urlParams.get('page') ?? DEFAULT_SEARCH_PARAMS.page), pageSize: Number(urlParams.get('pageSize') ?? DEFAULT_SEARCH_PARAMS.pageSize), }; From ca2f08f1dcc8ff2d8047e89d18dc53e8a7f2f618 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sun, 10 Aug 2025 22:07:20 +1000 Subject: [PATCH 2/2] Improve personal progress filters UI layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the search form to use the improved UI pattern from PR #137: - Changed form layout to horizontal with left-aligned labels (14/10 span) - Replaced "Classics Only" dropdown with Switch component - Added Typography.Title for "Personal Progress" section heading - Updated Alert message for better clarity when not logged in - Aligned all switches to the right with consistent styling - Used valuePropName="checked" for proper Switch integration The UI now matches the cleaner, more organized design pattern established in the search toggles PR. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../search-drawer/basic-search-form.tsx | 110 +++++++++++------- 1 file changed, 66 insertions(+), 44 deletions(-) diff --git a/app/components/search-drawer/basic-search-form.tsx b/app/components/search-drawer/basic-search-form.tsx index d4bdb04b..c802b794 100644 --- a/app/components/search-drawer/basic-search-form.tsx +++ b/app/components/search-drawer/basic-search-form.tsx @@ -1,12 +1,14 @@ 'use client'; import React from 'react'; -import { Form, InputNumber, Row, Col, Select, Input, Switch, Divider } from 'antd'; +import { Form, InputNumber, Row, Col, Select, Input, 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'; +const { Title } = Typography; + const BasicSearchForm: React.FC = () => { const { uiSearchParams, updateFilters } = useUISearchParams(); const { token, user_id } = useBoardProvider(); @@ -22,8 +24,59 @@ const BasicSearchForm: React.FC = () => { } }; + const renderLogbookSection = () => { + if (!isLoggedIn) { + return ( + + + + ); + } + + return ( + <> + + updateFilters({ hideAttempted: checked })} + /> + + + + updateFilters({ hideCompleted: checked })} + /> + + + + updateFilters({ showOnlyAttempted: checked })} + /> + + + + updateFilters({ showOnlyCompleted: checked })} + /> + + + ); + }; + return ( -
+ @@ -116,15 +169,12 @@ const BasicSearchForm: React.FC = () => { /> - - + + updateFilters({ onlyClassics: checked })} + /> @@ -144,39 +194,11 @@ const BasicSearchForm: React.FC = () => { updateFilters({ settername: e.target.value })} /> - {isLoggedIn && ( - <> - Personal Progress - - - updateFilters({ hideAttempted: checked })} - /> - - - - updateFilters({ hideCompleted: checked })} - /> - - - - updateFilters({ showOnlyAttempted: checked })} - /> - - - - updateFilters({ showOnlyCompleted: checked })} - /> - - - )} + + Personal Progress + + + {renderLogbookSection()} ); };