From fab358bfe6d09b2aa5628c4c592a7bfb47c0adfa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 23:38:31 +0000 Subject: [PATCH 1/4] feat: Add hold state filters (Starting, Hand, Foot, Finish) to climb search Extend the hold filter dropdown to include specific hold states beyond Include/Exclude. Users can now filter climbs that have specific holds marked as Starting, Hand, Foot, or Finish holds. - Add STARTING, HAND, FOOT, FINISH options to filter dropdown - Add color configuration for each hold state filter - Update tag display to show counts for all selected hold states - Add holdStateFilters support to backend package to match web package --- .../db/queries/climbs/create-climb-filters.ts | 43 +++++++++++-- .../search-drawer/climb-hold-search-form.tsx | 64 +++++++++++++------ 2 files changed, 81 insertions(+), 26 deletions(-) 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..f56a86aa 100644 --- a/packages/backend/src/db/queries/climbs/create-climb-filters.ts +++ b/packages/backend/src/db/queries/climbs/create-climb-filters.ts @@ -2,6 +2,8 @@ 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'; +export type HoldState = 'STARTING' | 'HAND' | 'FOOT' | 'FINISH' | 'OFF' | 'ANY' | 'NOT'; + export interface ClimbSearchParams { // Pagination page?: number; @@ -18,8 +20,8 @@ export interface ClimbSearchParams { settername?: string[]; onlyClassics?: 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; @@ -51,14 +53,28 @@ export const createClimbFilters = ( userId?: number, ) => { // Process hold filters - const holdsToFilter = Object.entries(searchParams.holdsFilter || {}).map(([key, state]) => [ - key.replace('hold_', ''), - state, - ]); + // holdsFilter can have values like: + // - 'ANY': hold must be present in the climb + // - 'NOT': hold must NOT be present in the climb + // - { state: 'STARTING' | 'HAND' | 'FOOT' | 'FINISH' }: hold must be present with that specific state + // - 'STARTING' | 'HAND' | 'FOOT' | 'FINISH': (after URL parsing) same as above + const holdsToFilter = Object.entries(searchParams.holdsFilter || {}).map(([key, stateOrValue]) => { + const holdId = key.replace('hold_', ''); + // Handle both object form { state: 'STARTING' } and string form 'STARTING' (after URL parsing) + const state = typeof stateOrValue === 'object' && stateOrValue !== null + ? (stateOrValue as { state: string }).state + : stateOrValue; + 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) + const holdStateFilters = holdsToFilter + .filter(([, value]) => ['STARTING', 'HAND', 'FOOT', 'FINISH'].includes(value as string)) + .map(([key, state]) => ({ holdId: Number(key), state: state as string })); + // 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 +137,17 @@ 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 + 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 +248,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 +271,12 @@ export const createClimbFilters = ( nameCondition, setterNameCondition, holdConditions, + holdStateConditions, tallClimbsConditions, sizeConditions, personalProgressConditions, anyHolds, notHolds, + holdStateFilters, }; }; 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..49cf20e9 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 @@ -2,13 +2,23 @@ import React from 'react'; import { BoardDetails, 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, PlayCircleOutlined, StopOutlined } from '@ant-design/icons'; import BoardHeatmap from '../board-renderer/board-heatmap'; import { track } from '@vercel/analytics'; import styles from './search-form.module.css'; const { Text } = Typography; +// Color configuration for each hold state filter +const HOLD_STATE_COLORS: Record = { + ANY: { color: '#06B6D4', label: 'Include', tagColor: 'cyan' }, + NOT: { color: '#EF4444', label: 'Exclude', tagColor: 'red' }, + STARTING: { color: '#00FF00', label: 'Starting', tagColor: 'green' }, + HAND: { color: '#00FFFF', label: 'Hand', tagColor: 'cyan' }, + FOOT: { color: '#FFA500', label: 'Foot', tagColor: 'orange' }, + FINISH: { color: '#FF00FF', label: 'Finish', tagColor: 'magenta' }, +}; + interface ClimbHoldSearchFormProps { boardDetails: BoardDetails; } @@ -21,16 +31,15 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails 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 = HOLD_STATE_COLORS[selectedState]; + updatedHoldsFilter[holdId] = { + state: selectedState, + color: stateConfig.color, + displayColor: stateConfig.color, + }; } updateFilters({ @@ -39,13 +48,22 @@ 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 + const holdStateCounts = React.useMemo(() => { + const counts: Record = {}; + Object.values(uiSearchParams.holdsFilter || {}).forEach(h => { + counts[h.state] = (counts[h.state] || 0) + 1; + }); + return counts; + }, [uiSearchParams.holdsFilter]); return (
@@ -62,7 +80,7 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails }); }} size="small" - style={{ width: 110 }} + style={{ width: 120 }} options={stateItems.map(item => ({ value: item.value, label: ( @@ -73,8 +91,16 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails ), }))} /> - {anyHoldsCount > 0 && {anyHoldsCount} in} - {notHoldsCount > 0 && {notHoldsCount} out} + {Object.entries(holdStateCounts).map(([state, count]) => { + const config = HOLD_STATE_COLORS[state]; + if (!config) return null; + const shortLabel = state === 'ANY' ? 'in' : state === 'NOT' ? 'out' : state.toLowerCase(); + return ( + + {count} {shortLabel} + + ); + })}
From c548416c5b174516bd70acd9859d1becc8b5e10e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 00:37:12 +0000 Subject: [PATCH 2/4] refactor: Use design tokens and shared types for hold state filters - Use themeTokens for UI colors (ANY/NOT) and HOLD_STATE_MAP for board-specific hold state colors - Replace emoji icons with consistent Ant Design icons - Import HoldState from @boardsesh/shared-schema instead of duplicating - Update ClimbSearchInput in shared-schema to support all hold states --- .../db/queries/climbs/create-climb-filters.ts | 3 +-- packages/shared-schema/src/types.ts | 4 +-- .../search-drawer/climb-hold-search-form.tsx | 26 +++++++++++-------- 3 files changed, 18 insertions(+), 15 deletions(-) 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 f56a86aa..a4318fc4 100644 --- a/packages/backend/src/db/queries/climbs/create-climb-filters.ts +++ b/packages/backend/src/db/queries/climbs/create-climb-filters.ts @@ -1,8 +1,7 @@ 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'; - -export type HoldState = 'STARTING' | 'HAND' | 'FOOT' | 'FINISH' | 'OFF' | 'ANY' | 'NOT'; +import type { HoldState } from '@boardsesh/shared-schema'; export interface ClimbSearchParams { // Pagination 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 49cf20e9..ae9800b3 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 @@ -2,21 +2,25 @@ import React from 'react'; import { BoardDetails, 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, PlayCircleOutlined, StopOutlined } 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; // Color configuration for each hold state filter +// UI-only states (ANY/NOT) use theme tokens, board hold states use HOLD_STATE_MAP colors const HOLD_STATE_COLORS: Record = { - ANY: { color: '#06B6D4', label: 'Include', tagColor: 'cyan' }, - NOT: { color: '#EF4444', label: 'Exclude', tagColor: 'red' }, - STARTING: { color: '#00FF00', label: 'Starting', tagColor: 'green' }, - HAND: { color: '#00FFFF', label: 'Hand', tagColor: 'cyan' }, - FOOT: { color: '#FFA500', label: 'Foot', tagColor: 'orange' }, - FINISH: { color: '#FF00FF', label: 'Finish', tagColor: 'magenta' }, + ANY: { color: themeTokens.colors.primary, label: 'Include', tagColor: 'cyan' }, + NOT: { color: themeTokens.colors.error, label: 'Exclude', tagColor: 'red' }, + // Board hold states - use standard kilter colors for consistency + STARTING: { color: HOLD_STATE_MAP.kilter[12].color, label: 'Starting', tagColor: 'green' }, + HAND: { color: HOLD_STATE_MAP.kilter[13].color, label: 'Hand', tagColor: 'cyan' }, + FOOT: { color: HOLD_STATE_MAP.kilter[15].color, label: 'Foot', tagColor: 'orange' }, + FINISH: { color: HOLD_STATE_MAP.kilter[14].color, label: 'Finish', tagColor: 'magenta' }, }; interface ClimbHoldSearchFormProps { @@ -50,10 +54,10 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails const stateItems = [ { 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: }, + { value: 'STARTING', label: 'Starting', icon: }, + { value: 'HAND', label: 'Hand', icon: }, + { value: 'FOOT', label: 'Foot', icon: }, + { value: 'FINISH', label: 'Finish', icon: }, ]; // Count holds by state for display From d348e20dcdfaa6df138cc4d887c1907c6344de00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 31 Dec 2025 01:49:11 +0000 Subject: [PATCH 3/4] fix: Address code review issues for hold state filters - Add type guard validation for hold states before SQL query to prevent injection (VALID_HOLD_STATE_FILTERS const with isValidHoldStateFilter) - Simplify ClimbSearchParams.holdsFilter type to Record - Use board-specific colors via getHoldStateColors(boardName) instead of hardcoded kilter colors - Document that OFF state is intentionally excluded from filter options --- .../db/queries/climbs/create-climb-filters.ts | 29 +++++----- .../search-drawer/climb-hold-search-form.tsx | 54 ++++++++++++------- 2 files changed, 50 insertions(+), 33 deletions(-) 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 a4318fc4..4a87c925 100644 --- a/packages/backend/src/db/queries/climbs/create-climb-filters.ts +++ b/packages/backend/src/db/queries/climbs/create-climb-filters.ts @@ -3,6 +3,14 @@ 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 page?: number; @@ -19,8 +27,8 @@ export interface ClimbSearchParams { settername?: string[]; onlyClassics?: boolean; onlyTallClimbs?: boolean; - // Hold filters - supports ANY, NOT, or specific hold states (STARTING, HAND, FOOT, FINISH) - holdsFilter?: Record; + // Hold filters - accepts HoldState strings (normalized during URL parsing) + holdsFilter?: Record; // Personal progress filters hideAttempted?: boolean; hideCompleted?: boolean; @@ -52,17 +60,9 @@ export const createClimbFilters = ( userId?: number, ) => { // Process hold filters - // holdsFilter can have values like: - // - 'ANY': hold must be present in the climb - // - 'NOT': hold must NOT be present in the climb - // - { state: 'STARTING' | 'HAND' | 'FOOT' | 'FINISH' }: hold must be present with that specific state - // - 'STARTING' | 'HAND' | 'FOOT' | 'FINISH': (after URL parsing) same as above - const holdsToFilter = Object.entries(searchParams.holdsFilter || {}).map(([key, stateOrValue]) => { + // holdsFilter values are HoldState strings: 'ANY', 'NOT', 'STARTING', 'HAND', 'FOOT', 'FINISH' + const holdsToFilter = Object.entries(searchParams.holdsFilter || {}).map(([key, state]) => { const holdId = key.replace('hold_', ''); - // Handle both object form { state: 'STARTING' } and string form 'STARTING' (after URL parsing) - const state = typeof stateOrValue === 'object' && stateOrValue !== null - ? (stateOrValue as { state: string }).state - : stateOrValue; return [holdId, state] as const; }); @@ -70,9 +70,10 @@ export const createClimbFilters = ( 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]) => ['STARTING', 'HAND', 'FOOT', 'FINISH'].includes(value as string)) - .map(([key, state]) => ({ holdId: Number(key), state: state as string })); + .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[] = [ 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 ae9800b3..f15f3932 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,5 +1,5 @@ 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, CaretRightOutlined, AimOutlined, VerticalAlignBottomOutlined, FlagOutlined } from '@ant-design/icons'; @@ -11,16 +11,26 @@ import styles from './search-form.module.css'; const { Text } = Typography; -// Color configuration for each hold state filter -// UI-only states (ANY/NOT) use theme tokens, board hold states use HOLD_STATE_MAP colors -const HOLD_STATE_COLORS: Record = { - ANY: { color: themeTokens.colors.primary, label: 'Include', tagColor: 'cyan' }, - NOT: { color: themeTokens.colors.error, label: 'Exclude', tagColor: 'red' }, - // Board hold states - use standard kilter colors for consistency - STARTING: { color: HOLD_STATE_MAP.kilter[12].color, label: 'Starting', tagColor: 'green' }, - HAND: { color: HOLD_STATE_MAP.kilter[13].color, label: 'Hand', tagColor: 'cyan' }, - FOOT: { color: HOLD_STATE_MAP.kilter[15].color, label: 'Foot', tagColor: 'orange' }, - FINISH: { color: HOLD_STATE_MAP.kilter[14].color, label: 'Finish', tagColor: 'magenta' }, +// 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 { @@ -31,6 +41,12 @@ 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; @@ -38,7 +54,7 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails if (wasSelected) { delete updatedHoldsFilter[holdId]; } else { - const stateConfig = HOLD_STATE_COLORS[selectedState]; + const stateConfig = holdStateColors[selectedState]; updatedHoldsFilter[holdId] = { state: selectedState, color: stateConfig.color, @@ -52,12 +68,12 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails }; const stateItems = [ - { 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: }, + { 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: }, ]; // Count holds by state for display @@ -96,7 +112,7 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails }))} /> {Object.entries(holdStateCounts).map(([state, count]) => { - const config = HOLD_STATE_COLORS[state]; + const config = holdStateColors[state as keyof typeof holdStateColors]; if (!config) return null; const shortLabel = state === 'ANY' ? 'in' : state === 'NOT' ? 'out' : state.toLowerCase(); return ( From 3c3c69120b8d512d5d04918d34b679238f7bc29c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 04:41:40 +0000 Subject: [PATCH 4/4] fix: Handle OFF state and add defensive checks for hold filters - Explicitly filter out 'OFF' state in backend (it means "hold not used") - Skip 'OFF' state when counting holds for tag display in UI - Add comments explaining SQL parameterization safety via type guard - Add defensive null check with comment for unknown states in tag render --- .../src/db/queries/climbs/create-climb-filters.ts | 13 +++++++++---- .../search-drawer/climb-hold-search-form.tsx | 6 +++++- 2 files changed, 14 insertions(+), 5 deletions(-) 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 4a87c925..a19c7703 100644 --- a/packages/backend/src/db/queries/climbs/create-climb-filters.ts +++ b/packages/backend/src/db/queries/climbs/create-climb-filters.ts @@ -61,10 +61,13 @@ export const createClimbFilters = ( ) => { // Process hold filters // holdsFilter values are HoldState strings: 'ANY', 'NOT', 'STARTING', 'HAND', 'FOOT', 'FINISH' - const holdsToFilter = Object.entries(searchParams.holdsFilter || {}).map(([key, state]) => { - const holdId = key.replace('hold_', ''); - return [holdId, state] as const; - }); + // 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)); @@ -138,6 +141,8 @@ export const createClimbFilters = ( ]; // 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 ( 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 f15f3932..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 @@ -77,9 +77,12 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails ]; // 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; @@ -111,9 +114,10 @@ const ClimbHoldSearchForm: React.FC = ({ boardDetails ), }))} /> + {/* 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; + if (!config) return null; // Skip unknown states defensively const shortLabel = state === 'ANY' ? 'in' : state === 'NOT' ? 'out' : state.toLowerCase(); return (