diff --git a/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts b/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts index 57d61dbe..bb7abbef 100644 --- a/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts +++ b/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts @@ -1,4 +1,4 @@ -import { getHoldHeatmapData } from '@/app/lib/db/queries/climbs/holds-heatmap'; +import { getHoldHeatmapData, HoldsWithStateFilter } from '@/app/lib/db/queries/climbs/holds-heatmap'; import { getSession } from '@/app/lib/session'; import { BoardRouteParameters, ErrorResponse, SearchRequestPagination } from '@/app/lib/types'; import { urlParamsToSearchParams } from '@/app/lib/url-utils'; @@ -60,8 +60,19 @@ export async function GET( userId = session.userId; } + // Parse holdsWithState filter if provided (JSON stringified object) + let holdsWithState: HoldsWithStateFilter | undefined; + const holdsWithStateParam = query.get('holdsWithState'); + if (holdsWithStateParam) { + try { + holdsWithState = JSON.parse(holdsWithStateParam); + } catch { + console.warn('Invalid holdsWithState parameter, ignoring'); + } + } + // Get the heatmap data using the query function - const holdStats = await getHoldHeatmapData(parsedParams, searchParams, userId); + const holdStats = await getHoldHeatmapData(parsedParams, searchParams, userId, holdsWithState); // Return response return NextResponse.json({ diff --git a/app/components/board-renderer/board-heatmap.tsx b/app/components/board-renderer/board-heatmap.tsx index 72729910..eed3b0e7 100644 --- a/app/components/board-renderer/board-heatmap.tsx +++ b/app/components/board-renderer/board-heatmap.tsx @@ -5,12 +5,14 @@ import { BoardDetails } from '@/app/lib/types'; import { HeatmapData } from './types'; import { LitUpHoldsMap } from './types'; import { scaleLog } from 'd3-scale'; -import useHeatmapData from '../search-drawer/use-heatmap'; -import { usePathname, useSearchParams } from 'next/navigation'; -import { useUISearchParams } from '@/app/components/queue-control/ui-searchparams-provider'; +import useHeatmapData, { HoldsWithStateFilter } from '../search-drawer/use-heatmap'; +import { usePathname } from 'next/navigation'; +import { useUISearchParamsOptional } from '@/app/components/queue-control/ui-searchparams-provider'; import { Button, Select, Form, Switch } from 'antd'; import { track } from '@vercel/analytics'; import BoardRenderer from './board-renderer'; +import { SearchRequestPagination } from '@/app/lib/types'; +import { DEFAULT_SEARCH_PARAMS } from '@/app/lib/url-utils'; const LEGEND_HEIGHT = 96; // Increased from 80 const BLUR_RADIUS = 10; // Increased blur radius @@ -41,6 +43,9 @@ interface BoardHeatmapProps { boardDetails: BoardDetails; litUpHoldsMap?: LitUpHoldsMap; onHoldClick?: (holdId: number) => void; + holdsWithState?: HoldsWithStateFilter; // Filter for create climb heatmap + angle?: number; // Optional angle override (used by create climb) + filters?: SearchRequestPagination; // Optional filters override (used by create climb) } // Define the color mode type including user-specific modes @@ -55,17 +60,30 @@ type ColorMode = | 'userAscents' | 'userAttempts'; -const BoardHeatmap: React.FC = ({ boardDetails, litUpHoldsMap, onHoldClick }) => { +const BoardHeatmap: React.FC = ({ + boardDetails, + litUpHoldsMap, + onHoldClick, + holdsWithState, + angle: angleProp, + filters: filtersProp, +}) => { const pathname = usePathname(); - const searchParams = useSearchParams(); - const { uiSearchParams } = useUISearchParams(); + + // Use uiSearchParams context if available and no filters prop provided + const uiSearchParamsContext = useUISearchParamsOptional(); + const uiSearchParams = uiSearchParamsContext?.uiSearchParams; const [colorMode, setColorMode] = useState('ascents'); const [showNumbers, setShowNumbers] = useState(false); const [showHeatmap, setShowHeatmap] = useState(false); - // Get angle from pathname - derived directly without needing state - const angle = useMemo(() => getAngleFromPath(pathname), [pathname]); + // Get angle from pathname if not provided as prop + const angleFromPath = useMemo(() => getAngleFromPath(pathname), [pathname]); + const angle = angleProp ?? angleFromPath; + + // Use provided filters or fall back to uiSearchParams (context may not be available in create climb screen) + const filters = filtersProp ?? uiSearchParams ?? DEFAULT_SEARCH_PARAMS; // Only fetch heatmap data when heatmap is enabled const { data: heatmapData = [], loading: heatmapLoading } = useHeatmapData({ @@ -74,8 +92,9 @@ const BoardHeatmap: React.FC = ({ boardDetails, litUpHoldsMap sizeId: boardDetails.size_id, setIds: boardDetails.set_ids.join(','), angle, - filters: uiSearchParams, + filters, enabled: showHeatmap, + holdsWithState, }); const [threshold, setThreshold] = useState(1); @@ -299,7 +318,7 @@ const BoardHeatmap: React.FC = ({ boardDetails, litUpHoldsMap )} diff --git a/app/components/create-climb/create-climb-form.tsx b/app/components/create-climb/create-climb-form.tsx index 6538bfe5..50f72b5f 100644 --- a/app/components/create-climb/create-climb-form.tsx +++ b/app/components/create-climb/create-climb-form.tsx @@ -1,18 +1,31 @@ 'use client'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Form, Input, Switch, Button, Typography, Flex, Space, Tag, Modal, Alert } from 'antd'; import { BulbOutlined, BulbFilled, ExperimentOutlined } from '@ant-design/icons'; import { useRouter } from 'next/navigation'; import { track } from '@vercel/analytics'; -import BoardRenderer from '../board-renderer/board-renderer'; +import BoardHeatmap from '../board-renderer/board-heatmap'; import { useBoardProvider } from '../board-provider/board-provider-context'; import { useCreateClimb } from './use-create-climb'; import { useBoardBluetooth } from '../board-bluetooth-control/use-board-bluetooth'; import { BoardDetails } from '@/app/lib/types'; -import { constructClimbListWithSlugs } from '@/app/lib/url-utils'; +import { constructClimbListWithSlugs, DEFAULT_SEARCH_PARAMS } from '@/app/lib/url-utils'; +import { HoldsWithStateFilter } from '../search-drawer/use-heatmap'; +import { LitUpHoldsMap } from '../board-renderer/types'; import '../board-bluetooth-control/send-climb-to-board-button.css'; +// Helper function to convert litUpHoldsMap to holdsWithState filter format +function convertToHoldsWithState(litUpHoldsMap: LitUpHoldsMap): HoldsWithStateFilter { + const holdsWithState: HoldsWithStateFilter = {}; + for (const [holdId, hold] of Object.entries(litUpHoldsMap)) { + if (hold.state && hold.state !== 'OFF') { + holdsWithState[holdId] = hold.state; + } + } + return holdsWithState; +} + const { TextArea } = Input; const { Title, Text } = Typography; @@ -43,6 +56,9 @@ export default function CreateClimbForm({ boardDetails, angle }: CreateClimbForm const { isConnected, loading: bluetoothLoading, connect, sendFramesToBoard } = useBoardBluetooth({ boardDetails }); + // Convert litUpHoldsMap to holdsWithState format for heatmap filtering + const holdsWithState = useMemo(() => convertToHoldsWithState(litUpHoldsMap), [litUpHoldsMap]); + const [form] = Form.useForm(); const [loginForm] = Form.useForm<{ username: string; password: string }>(); const [isSaving, setIsSaving] = useState(false); @@ -212,17 +228,19 @@ export default function CreateClimbForm({ boardDetails, angle }: CreateClimbForm - {/* Board with clickable holds */} + {/* Board with clickable holds and heatmap */}
Tap holds to set their type. Tap again to cycle through types. {isConnected && ' Changes are shown live on the board.'} -
diff --git a/app/components/queue-control/ui-searchparams-provider.tsx b/app/components/queue-control/ui-searchparams-provider.tsx index 44191c1e..2f5c7be8 100644 --- a/app/components/queue-control/ui-searchparams-provider.tsx +++ b/app/components/queue-control/ui-searchparams-provider.tsx @@ -86,3 +86,8 @@ export const useUISearchParams = () => { } return context; }; + +// Optional version that returns null if not in context (for components that can work without it) +export const useUISearchParamsOptional = () => { + return useContext(UISearchParamsContext); +}; diff --git a/app/components/search-drawer/use-heatmap.tsx b/app/components/search-drawer/use-heatmap.tsx index f3e81de5..9ff68136 100644 --- a/app/components/search-drawer/use-heatmap.tsx +++ b/app/components/search-drawer/use-heatmap.tsx @@ -4,6 +4,11 @@ import { HeatmapData } from '../board-renderer/types'; import { searchParamsToUrlParams } from '@/app/lib/url-utils'; import { useBoardProvider } from '../board-provider/board-provider-context'; +// Filter type for holds with their states (used in create climb heatmap) +export interface HoldsWithStateFilter { + [holdId: string]: string; // holdId -> HoldState (STARTING, HAND, FOOT, FINISH) +} + interface UseHeatmapDataProps { boardName: BoardName; layoutId: number; @@ -12,6 +17,7 @@ interface UseHeatmapDataProps { angle: number; filters: SearchRequestPagination; enabled?: boolean; + holdsWithState?: HoldsWithStateFilter; // Optional filter for create climb heatmap } export default function useHeatmapData({ @@ -22,12 +28,17 @@ export default function useHeatmapData({ angle, filters, enabled = true, + holdsWithState, }: UseHeatmapDataProps) { const [heatmapData, setHeatmapData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { token, user_id } = useBoardProvider(); + // Serialize objects for stable comparison in useEffect + const holdsWithStateKey = holdsWithState ? JSON.stringify(holdsWithState) : ''; + const filtersKey = JSON.stringify(filters); + useEffect(() => { // Don't fetch if not enabled if (!enabled) { @@ -49,8 +60,16 @@ export default function useHeatmapData({ headers['x-user-id'] = user_id.toString(); } + // Build URL with query params + const urlParams = searchParamsToUrlParams(filters); + + // Add holdsWithState filter if provided and has entries + if (holdsWithState && Object.keys(holdsWithState).length > 0) { + urlParams.set('holdsWithState', JSON.stringify(holdsWithState)); + } + const response = await fetch( - `/api/v1/${boardName}/${layoutId}/${sizeId}/${setIds}/${angle}/heatmap?${searchParamsToUrlParams(filters).toString()}`, + `/api/v1/${boardName}/${layoutId}/${sizeId}/${setIds}/${angle}/heatmap?${urlParams.toString()}`, { headers }, ); @@ -82,7 +101,8 @@ export default function useHeatmapData({ return () => { cancelled = true; }; - }, [boardName, layoutId, sizeId, setIds, angle, filters, token, user_id, enabled]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [boardName, layoutId, sizeId, setIds, angle, filtersKey, token, user_id, enabled, holdsWithStateKey]); return { data: heatmapData, loading, error }; } diff --git a/app/lib/db/queries/climbs/holds-heatmap.ts b/app/lib/db/queries/climbs/holds-heatmap.ts index b59427ec..35ae775e 100644 --- a/app/lib/db/queries/climbs/holds-heatmap.ts +++ b/app/lib/db/queries/climbs/holds-heatmap.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, like, sql, SQL } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { dbz as db } from '@/app/lib/db/db'; import { ParsedBoardRouteParameters, SearchRequestPagination } from '@/app/lib/types'; @@ -19,10 +19,32 @@ export interface HoldHeatmapData { userAttempts?: number; } +// Maps hold state names to their codes for each board type +// These must match the format used in frames strings: p{holdId}r{stateCode} +const HOLD_STATE_TO_CODE: Record> = { + kilter: { + STARTING: 42, + HAND: 43, + FINISH: 44, + FOOT: 45, + }, + tension: { + STARTING: 1, + HAND: 2, + FINISH: 3, + FOOT: 4, + }, +}; + +export interface HoldsWithStateFilter { + [holdId: string]: string; // holdId -> HoldState (STARTING, HAND, FOOT, FINISH) +} + export const getHoldHeatmapData = async ( params: ParsedBoardRouteParameters, searchParams: SearchRequestPagination, userId?: number, + holdsWithState?: HoldsWithStateFilter, ): Promise => { const tables = getBoardTables(params.board_name); const climbHolds = tables.climbHolds; @@ -33,6 +55,22 @@ export const getHoldHeatmapData = async ( // Use the shared filter creator with the PS alias const filters = createClimbFilters(tables, params, searchParams, ps, userId); + // Build hold state filter conditions if provided + // These filter climbs to only include those that have ALL specified holds in their specified states + const holdStateConditions: SQL[] = []; + if (holdsWithState && Object.keys(holdsWithState).length > 0) { + const stateToCode = HOLD_STATE_TO_CODE[params.board_name]; + if (stateToCode) { + for (const [holdId, state] of Object.entries(holdsWithState)) { + const stateCode = stateToCode[state]; + if (stateCode !== undefined) { + // Match pattern: p{holdId}r{stateCode} in frames string + holdStateConditions.push(like(tables.climbs.frames, `%p${holdId}r${stateCode}%`)); + } + } + } + } + try { // Check if personal progress filters are active - if so, use user-specific counts const personalProgressFiltersEnabled = @@ -66,7 +104,7 @@ export const getHoldHeatmapData = async ( and(eq(tables.climbStats.climbUuid, climbHolds.climbUuid), eq(tables.climbStats.angle, params.angle)), ) .where( - and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions()), + and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions(), ...holdStateConditions), ) .groupBy(climbHolds.holdId); @@ -92,7 +130,7 @@ export const getHoldHeatmapData = async ( and(eq(tables.climbStats.climbUuid, climbHolds.climbUuid), eq(tables.climbStats.angle, params.angle)), ) .where( - and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions()), + and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions(), ...holdStateConditions), ) .groupBy(climbHolds.holdId);