diff --git a/packages/backend/src/db/queries/climbs/create-climb-filters.ts b/packages/backend/src/db/queries/climbs/create-climb-filters.ts index ecf539c5..a19c7703 100644 --- a/packages/backend/src/db/queries/climbs/create-climb-filters.ts +++ b/packages/backend/src/db/queries/climbs/create-climb-filters.ts @@ -1,6 +1,15 @@ import { eq, gte, sql, like, notLike, inArray, SQL } from 'drizzle-orm'; import { TableSet, getTableName, type BoardName } from '../util/table-select.js'; import type { SizeEdges } from '../util/product-sizes-data.js'; +import type { HoldState } from '@boardsesh/shared-schema'; + +// Valid hold states that can be used for state-specific filtering +// Note: 'OFF' is excluded as it represents "hold not used" and isn't useful as a filter +const VALID_HOLD_STATE_FILTERS = ['STARTING', 'HAND', 'FOOT', 'FINISH'] as const; +type ValidHoldStateFilter = typeof VALID_HOLD_STATE_FILTERS[number]; + +const isValidHoldStateFilter = (state: string): state is ValidHoldStateFilter => + VALID_HOLD_STATE_FILTERS.includes(state as ValidHoldStateFilter); export interface ClimbSearchParams { // Pagination @@ -18,8 +27,8 @@ export interface ClimbSearchParams { settername?: string[]; onlyClassics?: boolean; onlyTallClimbs?: boolean; - // Hold filters - holdsFilter?: Record; + // Hold filters - accepts HoldState strings (normalized during URL parsing) + holdsFilter?: Record; // Personal progress filters hideAttempted?: boolean; hideCompleted?: boolean; @@ -51,14 +60,24 @@ export const createClimbFilters = ( userId?: number, ) => { // Process hold filters - const holdsToFilter = Object.entries(searchParams.holdsFilter || {}).map(([key, state]) => [ - key.replace('hold_', ''), - state, - ]); + // holdsFilter values are HoldState strings: 'ANY', 'NOT', 'STARTING', 'HAND', 'FOOT', 'FINISH' + // Note: 'OFF' state is filtered out as it represents "hold not used" and has no filter meaning + const holdsToFilter = Object.entries(searchParams.holdsFilter || {}) + .filter(([, state]) => state !== 'OFF') // Explicitly filter out OFF state + .map(([key, state]) => { + const holdId = key.replace('hold_', ''); + return [holdId, state] as const; + }); const anyHolds = holdsToFilter.filter(([, value]) => value === 'ANY').map(([key]) => Number(key)); const notHolds = holdsToFilter.filter(([, value]) => value === 'NOT').map(([key]) => Number(key)); + // Hold state filters - hold must be present with specific state (STARTING, HAND, FOOT, FINISH) + // Uses type guard to validate state before including in SQL query + const holdStateFilters = holdsToFilter + .filter(([, value]) => isValidHoldStateFilter(value)) + .map(([key, state]) => ({ holdId: Number(key), state: state as ValidHoldStateFilter })); + // Base conditions for filtering climbs that don't reference the product sizes table const baseConditions: SQL[] = [ eq(tables.climbs.layoutId, params.layout_id), @@ -121,6 +140,19 @@ export const createClimbFilters = ( ...notHolds.map((holdId) => notLike(tables.climbs.frames, `%${holdId}r%`)), ]; + // State-specific hold conditions - use climb_holds table to filter by hold_id AND hold_state + // Note: state values are safe - validated by isValidHoldStateFilter to only be STARTING/HAND/FOOT/FINISH + // The sql template literal parameterizes the value, preventing SQL injection + const climbHoldsTable = getTableName(params.board_name, 'climb_holds'); + const holdStateConditions: SQL[] = holdStateFilters.map(({ holdId, state }) => + sql`EXISTS ( + SELECT 1 FROM ${sql.identifier(climbHoldsTable)} ch + WHERE ch.climb_uuid = ${tables.climbs.uuid} + AND ch.hold_id = ${holdId} + AND ch.hold_state = ${state} + )` + ); + // Tall climbs filter condition // Only applies for Kilter Homewall (layout_id = 8) on the largest size // A "tall climb" is one that uses holds in the bottom rows that are only available on the largest size @@ -221,7 +253,7 @@ export const createClimbFilters = ( return { // Helper function to get all climb filtering conditions - getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...setterNameCondition, ...holdConditions, ...tallClimbsConditions, ...personalProgressConditions], + getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...setterNameCondition, ...holdConditions, ...holdStateConditions, ...tallClimbsConditions, ...personalProgressConditions], // Size-specific conditions getSizeConditions: () => sizeConditions, @@ -244,10 +276,12 @@ export const createClimbFilters = ( nameCondition, setterNameCondition, holdConditions, + holdStateConditions, tallClimbsConditions, sizeConditions, personalProgressConditions, anyHolds, notHolds, + holdStateFilters, }; }; diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 059b88cb..03f65c9a 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -131,8 +131,8 @@ export type ClimbSearchInput = { setterId?: number; onlyBenchmarks?: boolean; onlyTallClimbs?: boolean; - // Hold filters - holdsFilter?: Record; + // Hold filters - supports ANY, NOT, or specific hold states (STARTING, HAND, FOOT, FINISH) + holdsFilter?: Record; // Personal progress filters hideAttempted?: boolean; hideCompleted?: boolean; diff --git a/packages/web/app/components/search-drawer/climb-hold-search-form.tsx b/packages/web/app/components/search-drawer/climb-hold-search-form.tsx index e3596540..eace95b4 100644 --- a/packages/web/app/components/search-drawer/climb-hold-search-form.tsx +++ b/packages/web/app/components/search-drawer/climb-hold-search-form.tsx @@ -1,14 +1,38 @@ import React from 'react'; -import { BoardDetails, HoldState } from '@/app/lib/types'; +import { BoardDetails, BoardName, HoldState } from '@/app/lib/types'; import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider'; import { Select, Typography, Space, Tag } from 'antd'; -import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { CheckCircleOutlined, CloseCircleOutlined, CaretRightOutlined, AimOutlined, VerticalAlignBottomOutlined, FlagOutlined } from '@ant-design/icons'; import BoardHeatmap from '../board-renderer/board-heatmap'; +import { HOLD_STATE_MAP } from '../board-renderer/types'; import { track } from '@vercel/analytics'; +import { themeTokens } from '@/app/theme/theme-config'; import styles from './search-form.module.css'; const { Text } = Typography; +// Hold code mappings per board for each state +// These codes correspond to entries in HOLD_STATE_MAP +const HOLD_STATE_CODES: Record> = { + kilter: { STARTING: 12, HAND: 13, FOOT: 15, FINISH: 14 }, + tension: { STARTING: 1, HAND: 2, FOOT: 4, FINISH: 3 }, +}; + +// Get hold state colors based on the current board +const getHoldStateColors = (boardName: BoardName) => { + const codes = HOLD_STATE_CODES[boardName]; + const boardMap = HOLD_STATE_MAP[boardName]; + + return { + ANY: { color: themeTokens.colors.primary, label: 'Include', tagColor: 'cyan' }, + NOT: { color: themeTokens.colors.error, label: 'Exclude', tagColor: 'red' }, + STARTING: { color: boardMap[codes.STARTING].color, label: 'Starting', tagColor: 'green' }, + HAND: { color: boardMap[codes.HAND].color, label: 'Hand', tagColor: 'cyan' }, + FOOT: { color: boardMap[codes.FOOT].color, label: 'Foot', tagColor: 'orange' }, + FINISH: { color: boardMap[codes.FINISH].color, label: 'Finish', tagColor: 'magenta' }, + }; +}; + interface ClimbHoldSearchFormProps { boardDetails: BoardDetails; } @@ -17,20 +41,25 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails const { uiSearchParams, updateFilters } = useUISearchParams(); const [selectedState, setSelectedState] = React.useState('ANY'); + // Get board-specific colors + const holdStateColors = React.useMemo( + () => getHoldStateColors(boardDetails.board_name), + [boardDetails.board_name] + ); + const handleHoldClick = (holdId: number) => { const updatedHoldsFilter = { ...uiSearchParams.holdsFilter }; const wasSelected = updatedHoldsFilter[holdId]?.state === selectedState; - if (selectedState === 'ANY' || selectedState === 'NOT') { - if (wasSelected) { - delete updatedHoldsFilter[holdId]; - } else { - updatedHoldsFilter[holdId] = { - state: selectedState, - color: selectedState === 'ANY' ? '#06B6D4' : '#EF4444', - displayColor: selectedState === 'ANY' ? '#06B6D4' : '#EF4444', - }; - } + if (wasSelected) { + delete updatedHoldsFilter[holdId]; + } else { + const stateConfig = holdStateColors[selectedState]; + updatedHoldsFilter[holdId] = { + state: selectedState, + color: stateConfig.color, + displayColor: stateConfig.color, + }; } updateFilters({ @@ -39,13 +68,25 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails }; const stateItems = [ - { value: 'ANY', label: 'Include', icon: }, - { value: 'NOT', label: 'Exclude', icon: }, + { value: 'ANY', label: 'Include', icon: }, + { value: 'NOT', label: 'Exclude', icon: }, + { value: 'STARTING', label: 'Starting', icon: }, + { value: 'HAND', label: 'Hand', icon: }, + { value: 'FOOT', label: 'Foot', icon: }, + { value: 'FINISH', label: 'Finish', icon: }, ]; - const selectedHoldsCount = Object.keys(uiSearchParams.holdsFilter || {}).length; - const anyHoldsCount = Object.values(uiSearchParams.holdsFilter || {}).filter(h => h.state === 'ANY').length; - const notHoldsCount = Object.values(uiSearchParams.holdsFilter || {}).filter(h => h.state === 'NOT').length; + // Count holds by state for display + // Note: 'OFF' state is excluded as it's not a valid filter option + const holdStateCounts = React.useMemo(() => { + const counts: Record = {}; + Object.values(uiSearchParams.holdsFilter || {}).forEach(h => { + // Skip OFF state - it means "hold not used" and shouldn't appear in filters + if (h.state === 'OFF') return; + counts[h.state] = (counts[h.state] || 0) + 1; + }); + return counts; + }, [uiSearchParams.holdsFilter]); return (
@@ -62,7 +103,7 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails }); }} size="small" - style={{ width: 110 }} + style={{ width: 120 }} options={stateItems.map(item => ({ value: item.value, label: ( @@ -73,8 +114,17 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails ), }))} /> - {anyHoldsCount > 0 && {anyHoldsCount} in} - {notHoldsCount > 0 && {notHoldsCount} out} + {/* Render tags for each state with count - unknown states are safely skipped */} + {Object.entries(holdStateCounts).map(([state, count]) => { + const config = holdStateColors[state as keyof typeof holdStateColors]; + if (!config) return null; // Skip unknown states defensively + const shortLabel = state === 'ANY' ? 'in' : state === 'NOT' ? 'out' : state.toLowerCase(); + return ( + + {count} {shortLabel} + + ); + })}