From 8ddd07db874ef66e1057bdf19a01ef0072797cc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Nov 2025 07:48:04 +0000 Subject: [PATCH 1/5] Add similar climbs feature to climb view page - Created database query function to find climbs that use all holds of current climb plus additional holds - Implemented expandable Similar Climbs section above beta videos - Added API endpoint at /api/internal/similar-climbs for fetching similar climbs - Displays similar climbs in a responsive grid with climb cards - Helps users discover bigger versions of climbs created for larger board sizes --- .../[angle]/view/[climb_uuid]/page.tsx | 6 + app/api/internal/similar-climbs/route.ts | 55 ++++++++ .../similar-climbs/similar-climbs.tsx | 117 ++++++++++++++++++ app/lib/db/queries/climbs/similar-climbs.ts | 103 +++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 app/api/internal/similar-climbs/route.ts create mode 100644 app/components/similar-climbs/similar-climbs.tsx create mode 100644 app/lib/db/queries/climbs/similar-climbs.ts diff --git a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx index 9e9eac7..7ccf9fc 100644 --- a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx +++ b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx @@ -22,6 +22,7 @@ import { dbz } from '@/app/lib/db/db'; import { kilterBetaLinks, tensionBetaLinks } from '@/app/lib/db/schema'; import { eq } from 'drizzle-orm'; import { BetaLink } from '@/app/lib/api-wrappers/sync-api-types'; +import SimilarClimbs from '@/app/components/similar-climbs/similar-climbs'; export async function generateMetadata(props: { params: Promise }): Promise { const params = await props.params; @@ -204,6 +205,11 @@ export default async function DynamicResultsPage(props: { params: Promise + diff --git a/app/api/internal/similar-climbs/route.ts b/app/api/internal/similar-climbs/route.ts new file mode 100644 index 0000000..2b57772 --- /dev/null +++ b/app/api/internal/similar-climbs/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSimilarClimbs } from '@/app/lib/db/queries/climbs/similar-climbs'; +import { BoardName } from '@/app/lib/types'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const board_name = searchParams.get('board_name'); + const layout_id = parseInt(searchParams.get('layout_id') || ''); + const size_id = parseInt(searchParams.get('size_id') || ''); + const set_ids_param = searchParams.get('set_ids'); + const angle = parseInt(searchParams.get('angle') || '40'); + const climb_uuid = searchParams.get('climb_uuid'); + const limit = parseInt(searchParams.get('limit') || '10'); + + if (!board_name || isNaN(layout_id) || isNaN(size_id) || !set_ids_param || !climb_uuid) { + return NextResponse.json( + { error: 'Missing required parameters: board_name, layout_id, size_id, set_ids, climb_uuid' }, + { status: 400 }, + ); + } + + const set_ids = set_ids_param.split(',').map((id) => parseInt(id.trim())); + + if (set_ids.some((id) => isNaN(id))) { + return NextResponse.json({ error: 'Invalid set_ids format' }, { status: 400 }); + } + + const climbs = await getSimilarClimbs( + { + board_name: board_name as BoardName, + layout_id, + size_id, + set_ids, + angle, + climb_uuid, + }, + limit, + ); + + return NextResponse.json( + { climbs }, + { + headers: { + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + }, + }, + ); + } catch (error) { + console.error('Error fetching similar climbs:', error); + return NextResponse.json({ error: 'Failed to fetch similar climbs' }, { status: 500 }); + } +} diff --git a/app/components/similar-climbs/similar-climbs.tsx b/app/components/similar-climbs/similar-climbs.tsx new file mode 100644 index 0000000..a447e63 --- /dev/null +++ b/app/components/similar-climbs/similar-climbs.tsx @@ -0,0 +1,117 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Collapse, Row, Col, Typography, Empty, Spin } from 'antd'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { BoardDetails, ParsedBoardRouteParametersWithUuid } from '@/app/lib/types'; +import ClimbCard from '../climb-card/climb-card'; +import { SimilarClimb } from '@/app/lib/db/queries/climbs/similar-climbs'; +import { PlusCircleOutlined, FireOutlined } from '@ant-design/icons'; + +const { Panel } = Collapse; +const { Text } = Typography; + +interface SimilarClimbsProps { + boardDetails: BoardDetails; + params: ParsedBoardRouteParametersWithUuid; + currentClimbName?: string; +} + +const SimilarClimbs: React.FC = ({ boardDetails, params, currentClimbName }) => { + const [similarClimbs, setSimilarClimbs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + // Only fetch when the panel is expanded and we haven't fetched yet + if (isExpanded && similarClimbs.length === 0 && !loading) { + fetchSimilarClimbs(); + } + }, [isExpanded]); + + const fetchSimilarClimbs = async () => { + setLoading(true); + setError(null); + + try { + const queryParams = new URLSearchParams({ + board_name: params.board_name, + layout_id: params.layout_id.toString(), + size_id: params.size_id.toString(), + set_ids: params.set_ids.join(','), + angle: params.angle.toString(), + climb_uuid: params.climb_uuid, + }); + + const response = await fetch(`/api/internal/similar-climbs?${queryParams}`); + + if (!response.ok) { + throw new Error('Failed to fetch similar climbs'); + } + + const data = await response.json(); + setSimilarClimbs(data.climbs || []); + } catch (err) { + console.error('Error fetching similar climbs:', err); + setError('Failed to load similar climbs'); + } finally { + setLoading(false); + } + }; + + const handleCollapseChange = (key: string | string[]) => { + setIsExpanded(Array.isArray(key) ? key.length > 0 : !!key); + }; + + return ( + + + + Similar Climbs + {similarClimbs.length > 0 && ({similarClimbs.length})} + + } + key="similar-climbs" + > + {loading ? ( +
+ +
+ ) : error ? ( + + ) : similarClimbs.length === 0 ? ( + + ) : ( + <> +
+ + These climbs use all the holds from{' '} + {currentClimbName || 'this climb'} plus additional holds. + They may be versions created for larger board sizes. + +
+ + {similarClimbs.map((climb) => ( + + , ]} + /> + + ))} + + + )} +
+
+ ); +}; + +export default SimilarClimbs; diff --git a/app/lib/db/queries/climbs/similar-climbs.ts b/app/lib/db/queries/climbs/similar-climbs.ts new file mode 100644 index 0000000..e183405 --- /dev/null +++ b/app/lib/db/queries/climbs/similar-climbs.ts @@ -0,0 +1,103 @@ +import { eq, and, sql, inArray, ne } from 'drizzle-orm'; +import { dbz as db } from '@/app/lib/db/db'; +import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; +import { Climb, ParsedBoardRouteParametersWithUuid } from '@/app/lib/types'; +import { getBoardTables } from '@/lib/db/queries/util/table-select'; + +export interface SimilarClimb extends Climb { + totalHolds: number; + matchingHolds: number; +} + +/** + * Find similar climbs that contain all holds of the current climb plus potentially more. + * This is useful for finding "bigger" versions of climbs on larger board sizes. + */ +export const getSimilarClimbs = async ( + params: ParsedBoardRouteParametersWithUuid, + limit: number = 10, +): Promise => { + const tables = getBoardTables(params.board_name); + + try { + // First, get all hold_ids for the current climb + const currentClimbHolds = await db + .select({ holdId: tables.climbHolds.holdId }) + .from(tables.climbHolds) + .where(eq(tables.climbHolds.climbUuid, params.climb_uuid)); + + if (currentClimbHolds.length === 0) { + return []; + } + + const currentHoldIds = currentClimbHolds.map((h) => h.holdId); + const currentHoldCount = currentHoldIds.length; + + // Find climbs that contain all the holds of the current climb + // Using a subquery to count matching holds per climb + const results = await db.execute(sql` + WITH current_climb_holds AS ( + SELECT hold_id + FROM ${tables.climbHolds} + WHERE climb_uuid = ${params.climb_uuid} + ), + candidate_climbs AS ( + SELECT + ch.climb_uuid, + COUNT(DISTINCT ch.hold_id) as matching_holds, + (SELECT COUNT(*) FROM ${tables.climbHolds} WHERE climb_uuid = ch.climb_uuid) as total_holds + FROM ${tables.climbHolds} ch + WHERE ch.hold_id IN (SELECT hold_id FROM current_climb_holds) + AND ch.climb_uuid != ${params.climb_uuid} + GROUP BY ch.climb_uuid + HAVING COUNT(DISTINCT ch.hold_id) = ${currentHoldCount} + ) + SELECT + c.uuid, + c.setter_username, + c.name, + c.description, + c.frames, + COALESCE(cs.angle, ${params.angle}) as angle, + COALESCE(cs.ascensionist_count, 0) as ascensionist_count, + dg.boulder_name as difficulty, + ROUND(cs.quality_average::numeric, 2) as quality_average, + ROUND(cs.difficulty_average::numeric - cs.display_difficulty::numeric, 2) as difficulty_error, + cs.benchmark_difficulty, + cc.total_holds, + cc.matching_holds + FROM candidate_climbs cc + INNER JOIN ${tables.climbs} c ON c.uuid = cc.climb_uuid + LEFT JOIN ${tables.climbStats} cs ON cs.climb_uuid = c.uuid AND cs.angle = ${params.angle} + LEFT JOIN ${tables.difficultyGrades} dg ON dg.difficulty = ROUND(cs.display_difficulty::numeric) + WHERE c.layout_id = ${params.layout_id} + AND c.frames_count = 1 + ORDER BY cc.total_holds ASC, cs.ascensionist_count DESC NULLS LAST + LIMIT ${limit} + `); + + // Transform results to Climb objects + const climbs: SimilarClimb[] = results.rows.map((row: any) => ({ + uuid: row.uuid, + setter_username: row.setter_username || '', + name: row.name || '', + description: row.description || '', + frames: row.frames || '', + angle: Number(row.angle || params.angle), + ascensionist_count: Number(row.ascensionist_count || 0), + difficulty: row.difficulty || '', + quality_average: row.quality_average?.toString() || '0', + stars: Math.round((Number(row.quality_average) || 0) * 5), + difficulty_error: row.difficulty_error?.toString() || '0', + benchmark_difficulty: row.benchmark_difficulty?.toString() || null, + litUpHoldsMap: convertLitUpHoldsStringToMap(row.frames || '', params.board_name)[0], + totalHolds: Number(row.total_holds || 0), + matchingHolds: Number(row.matching_holds || 0), + })); + + return climbs; + } catch (error) { + console.error('Error in getSimilarClimbs:', error); + throw error; + } +}; From 3a95e363da463c174922ecc8f1207c8c54b49556 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Nov 2025 07:53:32 +0000 Subject: [PATCH 2/5] Fix ESLint and TypeScript warnings in similar climbs feature - Remove unused imports (and, inArray, ne) from similar-climbs.ts - Add SimilarClimbRow interface to replace 'any' type - Fix React Hook useEffect dependencies with useCallback and useRef - Prevent duplicate API calls with hasFetchedRef --- .../similar-climbs/similar-climbs.tsx | 22 +++++--- app/lib/db/queries/climbs/similar-climbs.ts | 55 +++++++++++++------ 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/app/components/similar-climbs/similar-climbs.tsx b/app/components/similar-climbs/similar-climbs.tsx index a447e63..2552cb7 100644 --- a/app/components/similar-climbs/similar-climbs.tsx +++ b/app/components/similar-climbs/similar-climbs.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Collapse, Row, Col, Typography, Empty, Spin } from 'antd'; import { InfoCircleOutlined } from '@ant-design/icons'; import { BoardDetails, ParsedBoardRouteParametersWithUuid } from '@/app/lib/types'; @@ -22,17 +22,14 @@ const SimilarClimbs: React.FC = ({ boardDetails, params, cur const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [isExpanded, setIsExpanded] = useState(false); + const hasFetchedRef = useRef(false); - useEffect(() => { - // Only fetch when the panel is expanded and we haven't fetched yet - if (isExpanded && similarClimbs.length === 0 && !loading) { - fetchSimilarClimbs(); - } - }, [isExpanded]); + const fetchSimilarClimbs = useCallback(async () => { + if (hasFetchedRef.current) return; - const fetchSimilarClimbs = async () => { setLoading(true); setError(null); + hasFetchedRef.current = true; try { const queryParams = new URLSearchParams({ @@ -58,7 +55,14 @@ const SimilarClimbs: React.FC = ({ boardDetails, params, cur } finally { setLoading(false); } - }; + }, [params]); + + useEffect(() => { + // Only fetch when the panel is expanded + if (isExpanded) { + fetchSimilarClimbs(); + } + }, [isExpanded, fetchSimilarClimbs]); const handleCollapseChange = (key: string | string[]) => { setIsExpanded(Array.isArray(key) ? key.length > 0 : !!key); diff --git a/app/lib/db/queries/climbs/similar-climbs.ts b/app/lib/db/queries/climbs/similar-climbs.ts index e183405..51bc1c8 100644 --- a/app/lib/db/queries/climbs/similar-climbs.ts +++ b/app/lib/db/queries/climbs/similar-climbs.ts @@ -1,4 +1,4 @@ -import { eq, and, sql, inArray, ne } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { dbz as db } from '@/app/lib/db/db'; import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; import { Climb, ParsedBoardRouteParametersWithUuid } from '@/app/lib/types'; @@ -9,6 +9,22 @@ export interface SimilarClimb extends Climb { matchingHolds: number; } +interface SimilarClimbRow { + uuid: string; + setter_username: string | null; + name: string | null; + description: string | null; + frames: string | null; + angle: number | null; + ascensionist_count: number | null; + difficulty: string | null; + quality_average: number | null; + difficulty_error: number | null; + benchmark_difficulty: number | null; + total_holds: number; + matching_holds: number; +} + /** * Find similar climbs that contain all holds of the current climb plus potentially more. * This is useful for finding "bigger" versions of climbs on larger board sizes. @@ -77,23 +93,26 @@ export const getSimilarClimbs = async ( `); // Transform results to Climb objects - const climbs: SimilarClimb[] = results.rows.map((row: any) => ({ - uuid: row.uuid, - setter_username: row.setter_username || '', - name: row.name || '', - description: row.description || '', - frames: row.frames || '', - angle: Number(row.angle || params.angle), - ascensionist_count: Number(row.ascensionist_count || 0), - difficulty: row.difficulty || '', - quality_average: row.quality_average?.toString() || '0', - stars: Math.round((Number(row.quality_average) || 0) * 5), - difficulty_error: row.difficulty_error?.toString() || '0', - benchmark_difficulty: row.benchmark_difficulty?.toString() || null, - litUpHoldsMap: convertLitUpHoldsStringToMap(row.frames || '', params.board_name)[0], - totalHolds: Number(row.total_holds || 0), - matchingHolds: Number(row.matching_holds || 0), - })); + const climbs: SimilarClimb[] = results.rows.map((row) => { + const typedRow = row as unknown as SimilarClimbRow; + return { + uuid: typedRow.uuid, + setter_username: typedRow.setter_username || '', + name: typedRow.name || '', + description: typedRow.description || '', + frames: typedRow.frames || '', + angle: Number(typedRow.angle || params.angle), + ascensionist_count: Number(typedRow.ascensionist_count || 0), + difficulty: typedRow.difficulty || '', + quality_average: typedRow.quality_average?.toString() || '0', + stars: Math.round((Number(typedRow.quality_average) || 0) * 5), + difficulty_error: typedRow.difficulty_error?.toString() || '0', + benchmark_difficulty: typedRow.benchmark_difficulty?.toString() || null, + litUpHoldsMap: convertLitUpHoldsStringToMap(typedRow.frames || '', params.board_name)[0], + totalHolds: Number(typedRow.total_holds || 0), + matchingHolds: Number(typedRow.matching_holds || 0), + }; + }); return climbs; } catch (error) { From b6400e3ad5cf73a8ff2eed0534269b3fa0ef0f69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Nov 2025 08:00:12 +0000 Subject: [PATCH 3/5] Exclude Twister climbs from similar climbs results --- app/lib/db/queries/climbs/similar-climbs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/db/queries/climbs/similar-climbs.ts b/app/lib/db/queries/climbs/similar-climbs.ts index 51bc1c8..89935d8 100644 --- a/app/lib/db/queries/climbs/similar-climbs.ts +++ b/app/lib/db/queries/climbs/similar-climbs.ts @@ -88,6 +88,7 @@ export const getSimilarClimbs = async ( LEFT JOIN ${tables.difficultyGrades} dg ON dg.difficulty = ROUND(cs.display_difficulty::numeric) WHERE c.layout_id = ${params.layout_id} AND c.frames_count = 1 + AND c.name NOT ILIKE 'Twister%' ORDER BY cc.total_holds ASC, cs.ascensionist_count DESC NULLS LAST LIMIT ${limit} `); From 2c26921dbf998767ecaa6f7a2cb748fafc2ce2a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Nov 2025 08:08:45 +0000 Subject: [PATCH 4/5] Refactor similar climbs to use SSR and expand by default - Fetch similar climbs server-side in page component for better performance - Pass data as props to SimilarClimbs component instead of client-side fetching - Remove API endpoint /api/internal/similar-climbs (no longer needed) - Set defaultActiveKey to expand section by default - Simplify component by removing loading/error states and useEffect hooks - Follows Next.js 15 server-side rendering best practices --- .../[angle]/view/[climb_uuid]/page.tsx | 8 ++- app/api/internal/similar-climbs/route.ts | 55 --------------- .../similar-climbs/similar-climbs.tsx | 70 ++----------------- 3 files changed, 12 insertions(+), 121 deletions(-) delete mode 100644 app/api/internal/similar-climbs/route.ts diff --git a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx index 7ccf9fc..871e7e1 100644 --- a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx +++ b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx @@ -23,6 +23,7 @@ import { kilterBetaLinks, tensionBetaLinks } from '@/app/lib/db/schema'; import { eq } from 'drizzle-orm'; import { BetaLink } from '@/app/lib/api-wrappers/sync-api-types'; import SimilarClimbs from '@/app/components/similar-climbs/similar-climbs'; +import { getSimilarClimbs } from '@/app/lib/db/queries/climbs/similar-climbs'; export async function generateMetadata(props: { params: Promise }): Promise { const params = await props.params; @@ -165,11 +166,12 @@ export default async function DynamicResultsPage(props: { params: Promise diff --git a/app/api/internal/similar-climbs/route.ts b/app/api/internal/similar-climbs/route.ts deleted file mode 100644 index 2b57772..0000000 --- a/app/api/internal/similar-climbs/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getSimilarClimbs } from '@/app/lib/db/queries/climbs/similar-climbs'; -import { BoardName } from '@/app/lib/types'; - -export const runtime = 'nodejs'; - -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url); - const board_name = searchParams.get('board_name'); - const layout_id = parseInt(searchParams.get('layout_id') || ''); - const size_id = parseInt(searchParams.get('size_id') || ''); - const set_ids_param = searchParams.get('set_ids'); - const angle = parseInt(searchParams.get('angle') || '40'); - const climb_uuid = searchParams.get('climb_uuid'); - const limit = parseInt(searchParams.get('limit') || '10'); - - if (!board_name || isNaN(layout_id) || isNaN(size_id) || !set_ids_param || !climb_uuid) { - return NextResponse.json( - { error: 'Missing required parameters: board_name, layout_id, size_id, set_ids, climb_uuid' }, - { status: 400 }, - ); - } - - const set_ids = set_ids_param.split(',').map((id) => parseInt(id.trim())); - - if (set_ids.some((id) => isNaN(id))) { - return NextResponse.json({ error: 'Invalid set_ids format' }, { status: 400 }); - } - - const climbs = await getSimilarClimbs( - { - board_name: board_name as BoardName, - layout_id, - size_id, - set_ids, - angle, - climb_uuid, - }, - limit, - ); - - return NextResponse.json( - { climbs }, - { - headers: { - 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', - }, - }, - ); - } catch (error) { - console.error('Error fetching similar climbs:', error); - return NextResponse.json({ error: 'Failed to fetch similar climbs' }, { status: 500 }); - } -} diff --git a/app/components/similar-climbs/similar-climbs.tsx b/app/components/similar-climbs/similar-climbs.tsx index 2552cb7..7676ab3 100644 --- a/app/components/similar-climbs/similar-climbs.tsx +++ b/app/components/similar-climbs/similar-climbs.tsx @@ -1,9 +1,9 @@ 'use client'; -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Collapse, Row, Col, Typography, Empty, Spin } from 'antd'; +import React from 'react'; +import { Collapse, Row, Col, Typography, Empty } from 'antd'; import { InfoCircleOutlined } from '@ant-design/icons'; -import { BoardDetails, ParsedBoardRouteParametersWithUuid } from '@/app/lib/types'; +import { BoardDetails } from '@/app/lib/types'; import ClimbCard from '../climb-card/climb-card'; import { SimilarClimb } from '@/app/lib/db/queries/climbs/similar-climbs'; import { PlusCircleOutlined, FireOutlined } from '@ant-design/icons'; @@ -13,63 +13,13 @@ const { Text } = Typography; interface SimilarClimbsProps { boardDetails: BoardDetails; - params: ParsedBoardRouteParametersWithUuid; + similarClimbs: SimilarClimb[]; currentClimbName?: string; } -const SimilarClimbs: React.FC = ({ boardDetails, params, currentClimbName }) => { - const [similarClimbs, setSimilarClimbs] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [isExpanded, setIsExpanded] = useState(false); - const hasFetchedRef = useRef(false); - - const fetchSimilarClimbs = useCallback(async () => { - if (hasFetchedRef.current) return; - - setLoading(true); - setError(null); - hasFetchedRef.current = true; - - try { - const queryParams = new URLSearchParams({ - board_name: params.board_name, - layout_id: params.layout_id.toString(), - size_id: params.size_id.toString(), - set_ids: params.set_ids.join(','), - angle: params.angle.toString(), - climb_uuid: params.climb_uuid, - }); - - const response = await fetch(`/api/internal/similar-climbs?${queryParams}`); - - if (!response.ok) { - throw new Error('Failed to fetch similar climbs'); - } - - const data = await response.json(); - setSimilarClimbs(data.climbs || []); - } catch (err) { - console.error('Error fetching similar climbs:', err); - setError('Failed to load similar climbs'); - } finally { - setLoading(false); - } - }, [params]); - - useEffect(() => { - // Only fetch when the panel is expanded - if (isExpanded) { - fetchSimilarClimbs(); - } - }, [isExpanded, fetchSimilarClimbs]); - - const handleCollapseChange = (key: string | string[]) => { - setIsExpanded(Array.isArray(key) ? key.length > 0 : !!key); - }; - +const SimilarClimbs: React.FC = ({ boardDetails, similarClimbs, currentClimbName }) => { return ( - + @@ -80,13 +30,7 @@ const SimilarClimbs: React.FC = ({ boardDetails, params, cur } key="similar-climbs" > - {loading ? ( -
- -
- ) : error ? ( - - ) : similarClimbs.length === 0 ? ( + {similarClimbs.length === 0 ? ( Date: Sun, 9 Nov 2025 08:20:13 +0000 Subject: [PATCH 5/5] Hide similar climbs section on smallest board size - Fetch available sizes to determine the smallest size (minimum ID) - Only fetch similar climbs if not on the smallest size board - Conditionally render SimilarClimbs component based on board size - Improves performance by skipping unnecessary queries on small boards --- .../[angle]/view/[climb_uuid]/page.tsx | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx index 871e7e1..c51e162 100644 --- a/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx +++ b/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { notFound, permanentRedirect } from 'next/navigation'; import { BoardRouteParametersWithUuid } from '@/app/lib/types'; -import { getBoardDetails } from '@/app/lib/data/queries'; +import { getBoardDetails, getSizes } from '@/app/lib/data/queries'; import { getClimb } from '@/app/lib/data/queries'; import ClimbCard from '@/app/components/climb-card/climb-card'; import { Col, Row } from 'antd'; @@ -166,12 +166,18 @@ export default async function DynamicResultsPage(props: { params: Promise s.id)); + const isSmallestSize = parsedParams.size_id === minSizeId; + // Fetch all data in parallel const [boardDetails, currentClimb, betaLinks, similarClimbs] = await Promise.all([ getBoardDetails(parsedParams), getClimb(parsedParams), fetchBetaLinks(), - getSimilarClimbs(parsedParams, 10), + // Only fetch similar climbs if not on the smallest size + isSmallestSize ? Promise.resolve([]) : getSimilarClimbs(parsedParams, 10), ]); if (!currentClimb) { @@ -207,11 +213,13 @@ export default async function DynamicResultsPage(props: { params: Promise - + {!isSmallestSize && ( + + )}