Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 41 additions & 7 deletions packages/backend/src/db/queries/climbs/create-climb-filters.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,8 +27,8 @@
settername?: string[];
onlyClassics?: boolean;
onlyTallClimbs?: boolean;
// Hold filters
holdsFilter?: Record<string, 'ANY' | 'NOT'>;
// Hold filters - accepts HoldState strings (normalized during URL parsing)
holdsFilter?: Record<string, HoldState>;
// Personal progress filters
hideAttempted?: boolean;
hideCompleted?: boolean;
Expand Down Expand Up @@ -51,14 +60,24 @@
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),
Expand All @@ -70,7 +89,7 @@
// Size-specific conditions using pre-fetched static edge values
// This eliminates the need for a JOIN on product_sizes in the main query
const sizeConditions: SQL[] = [
sql`${tables.climbs.edgeLeft} > ${sizeEdges.edgeLeft}`,

Check failure on line 92 in packages/backend/src/db/queries/climbs/create-climb-filters.ts

View workflow job for this annotation

GitHub Actions / test

src/__tests__/climb-queries.test.ts > Climb Query Functions > countClimbs > should respect filters in count

TypeError: Cannot read properties of null (reading 'edgeLeft') ❯ createClimbFilters src/db/queries/climbs/create-climb-filters.ts:92:49 ❯ countClimbs src/db/queries/climbs/count-climbs.ts:27:19 ❯ src/__tests__/climb-queries.test.ts:221:35

Check failure on line 92 in packages/backend/src/db/queries/climbs/create-climb-filters.ts

View workflow job for this annotation

GitHub Actions / test

src/__tests__/climb-queries.test.ts > Climb Query Functions > countClimbs > should return accurate total count

TypeError: Cannot read properties of null (reading 'edgeLeft') ❯ createClimbFilters src/db/queries/climbs/create-climb-filters.ts:92:49 ❯ countClimbs src/db/queries/climbs/count-climbs.ts:27:19 ❯ src/__tests__/climb-queries.test.ts:202:27
sql`${tables.climbs.edgeRight} < ${sizeEdges.edgeRight}`,
sql`${tables.climbs.edgeBottom} > ${sizeEdges.edgeBottom}`,
sql`${tables.climbs.edgeTop} < ${sizeEdges.edgeTop}`,
Expand Down Expand Up @@ -121,6 +140,19 @@
...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
Expand Down Expand Up @@ -221,7 +253,7 @@

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,
Expand All @@ -244,10 +276,12 @@
nameCondition,
setterNameCondition,
holdConditions,
holdStateConditions,
tallClimbsConditions,
sizeConditions,
personalProgressConditions,
anyHolds,
notHolds,
holdStateFilters,
};
};
4 changes: 2 additions & 2 deletions packages/shared-schema/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ export type ClimbSearchInput = {
setterId?: number;
onlyBenchmarks?: boolean;
onlyTallClimbs?: boolean;
// Hold filters
holdsFilter?: Record<string, 'ANY' | 'NOT'>;
// Hold filters - supports ANY, NOT, or specific hold states (STARTING, HAND, FOOT, FINISH)
holdsFilter?: Record<string, HoldState>;
// Personal progress filters
hideAttempted?: boolean;
hideCompleted?: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BoardName, Record<string, number>> = {
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;
}
Expand All @@ -17,20 +41,25 @@ const ClimbHoldSearchForm: React.FC<ClimbHoldSearchFormProps> = ({ boardDetails
const { uiSearchParams, updateFilters } = useUISearchParams();
const [selectedState, setSelectedState] = React.useState<HoldState>('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({
Expand All @@ -39,13 +68,25 @@ const ClimbHoldSearchForm: React.FC<ClimbHoldSearchFormProps> = ({ boardDetails
};

const stateItems = [
{ value: 'ANY', label: 'Include', icon: <CheckCircleOutlined style={{ color: '#06B6D4' }} /> },
{ value: 'NOT', label: 'Exclude', icon: <CloseCircleOutlined style={{ color: '#EF4444' }} /> },
{ value: 'ANY', label: 'Include', icon: <CheckCircleOutlined style={{ color: holdStateColors.ANY.color }} /> },
{ value: 'NOT', label: 'Exclude', icon: <CloseCircleOutlined style={{ color: holdStateColors.NOT.color }} /> },
{ value: 'STARTING', label: 'Starting', icon: <CaretRightOutlined style={{ color: holdStateColors.STARTING.color }} /> },
{ value: 'HAND', label: 'Hand', icon: <AimOutlined style={{ color: holdStateColors.HAND.color }} /> },
{ value: 'FOOT', label: 'Foot', icon: <VerticalAlignBottomOutlined style={{ color: holdStateColors.FOOT.color }} /> },
{ value: 'FINISH', label: 'Finish', icon: <FlagOutlined style={{ color: holdStateColors.FINISH.color }} /> },
];

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<string, number> = {};
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 (
<div className={styles.holdSearchForm}>
Expand All @@ -62,7 +103,7 @@ const ClimbHoldSearchForm: React.FC<ClimbHoldSearchFormProps> = ({ boardDetails
});
}}
size="small"
style={{ width: 110 }}
style={{ width: 120 }}
options={stateItems.map(item => ({
value: item.value,
label: (
Expand All @@ -73,8 +114,17 @@ const ClimbHoldSearchForm: React.FC<ClimbHoldSearchFormProps> = ({ boardDetails
),
}))}
/>
{anyHoldsCount > 0 && <Tag color="cyan" style={{ margin: 0 }}>{anyHoldsCount} in</Tag>}
{notHoldsCount > 0 && <Tag color="red" style={{ margin: 0 }}>{notHoldsCount} out</Tag>}
{/* 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 (
<Tag key={state} color={config.tagColor} style={{ margin: 0 }}>
{count} {shortLabel}
</Tag>
);
})}
</Space>
</div>

Expand Down
Loading