From 7604a50cc96757c57957600b52619508b582bc3a Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Mon, 8 Dec 2025 12:08:25 +1100 Subject: [PATCH 1/3] Add heatmap feature to create climb screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When setting a new climb, users can now toggle a heatmap overlay that shows hold usage statistics filtered by the currently selected holds. The heatmap only shows data for climbs that contain ALL selected holds in their exact states (STARTING, HAND, FOOT, FINISH), helping route setters understand which holds are commonly used together. Changes: - Add holdsWithState filter to heatmap API and database query - Update BoardHeatmap component to accept filter props for create climb - Integrate BoardHeatmap into CreateClimbForm replacing BoardRenderer - Add useUISearchParamsOptional hook for components outside search context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../[set_ids]/[angle]/heatmap/route.ts | 15 ++++++- .../board-renderer/board-heatmap.tsx | 37 ++++++++++++---- .../create-climb/create-climb-form.tsx | 30 ++++++++++--- .../ui-searchparams-provider.tsx | 5 +++ app/components/search-drawer/use-heatmap.tsx | 19 +++++++- app/lib/db/queries/climbs/holds-heatmap.ts | 44 +++++++++++++++++-- 6 files changed, 128 insertions(+), 22 deletions(-) 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..f516264d 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); 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..729af47a 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,6 +28,7 @@ export default function useHeatmapData({ angle, filters, enabled = true, + holdsWithState, }: UseHeatmapDataProps) { const [heatmapData, setHeatmapData] = useState([]); const [loading, setLoading] = useState(false); @@ -49,8 +56,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 +97,7 @@ export default function useHeatmapData({ return () => { cancelled = true; }; - }, [boardName, layoutId, sizeId, setIds, angle, filters, token, user_id, enabled]); + }, [boardName, layoutId, sizeId, setIds, angle, filters, token, user_id, enabled, holdsWithState]); 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); From 6aa3f5f4d5461fb7a711ca207964eb7d9e58b0f6 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Mon, 8 Dec 2025 12:13:56 +1100 Subject: [PATCH 2/3] Fix board sizing - only add legend height when heatmap is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewBox was always including LEGEND_HEIGHT which made the board appear smaller to accommodate space for the legend. Now the legend height is only added when the heatmap is actively showing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/components/board-renderer/board-heatmap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/board-renderer/board-heatmap.tsx b/app/components/board-renderer/board-heatmap.tsx index f516264d..eed3b0e7 100644 --- a/app/components/board-renderer/board-heatmap.tsx +++ b/app/components/board-renderer/board-heatmap.tsx @@ -318,7 +318,7 @@ const BoardHeatmap: React.FC = ({ )} From 1246ebc0779057d886300aab554d3e673e7afc14 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Mon, 8 Dec 2025 12:32:23 +1100 Subject: [PATCH 3/3] Fix useHeatmapData re-render issues with object dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize holdsWithState and filters objects for stable comparison in useEffect dependency array to prevent infinite re-renders and request cancellations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/components/search-drawer/use-heatmap.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/components/search-drawer/use-heatmap.tsx b/app/components/search-drawer/use-heatmap.tsx index 729af47a..9ff68136 100644 --- a/app/components/search-drawer/use-heatmap.tsx +++ b/app/components/search-drawer/use-heatmap.tsx @@ -35,6 +35,10 @@ export default function useHeatmapData({ 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) { @@ -97,7 +101,8 @@ export default function useHeatmapData({ return () => { cancelled = true; }; - }, [boardName, layoutId, sizeId, setIds, angle, filters, token, user_id, enabled, holdsWithState]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [boardName, layoutId, sizeId, setIds, angle, filtersKey, token, user_id, enabled, holdsWithStateKey]); return { data: heatmapData, loading, error }; }