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
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,6 +22,8 @@ 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';
import { getSimilarClimbs } from '@/app/lib/db/queries/climbs/similar-climbs';

export async function generateMetadata(props: { params: Promise<BoardRouteParametersWithUuid> }): Promise<Metadata> {
const params = await props.params;
Expand Down Expand Up @@ -164,11 +166,18 @@ export default async function DynamicResultsPage(props: { params: Promise<BoardR
}
};

// Fetch the search results using searchCLimbs
const [boardDetails, currentClimb, betaLinks] = await Promise.all([
// Fetch sizes to determine if we're on the smallest size board
const sizes = await getSizes(parsedParams.board_name, parsedParams.layout_id);
const minSizeId = Math.min(...sizes.map((s) => 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(),
// Only fetch similar climbs if not on the smallest size
isSmallestSize ? Promise.resolve([]) : getSimilarClimbs(parsedParams, 10),
]);

if (!currentClimb) {
Expand Down Expand Up @@ -204,6 +213,13 @@ export default async function DynamicResultsPage(props: { params: Promise<BoardR
<ClimbCard climb={climbWithProcessedData} boardDetails={boardDetails} actions={[]} />
</Col>
<Col xs={24} lg={8}>
{!isSmallestSize && (
<SimilarClimbs
boardDetails={boardDetails}
similarClimbs={similarClimbs}
currentClimbName={currentClimb.name}
/>
)}
<BetaVideos betaLinks={betaLinks} />
</Col>
</Row>
Expand Down
65 changes: 65 additions & 0 deletions app/components/similar-climbs/similar-climbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';

import React from 'react';
import { Collapse, Row, Col, Typography, Empty } from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
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';

const { Panel } = Collapse;
const { Text } = Typography;

interface SimilarClimbsProps {
boardDetails: BoardDetails;
similarClimbs: SimilarClimb[];
currentClimbName?: string;
}

const SimilarClimbs: React.FC<SimilarClimbsProps> = ({ boardDetails, similarClimbs, currentClimbName }) => {
return (
<Collapse defaultActiveKey={['similar-climbs']} style={{ marginBottom: 16 }}>
<Panel
header={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<InfoCircleOutlined />
<span>Similar Climbs</span>
{similarClimbs.length > 0 && <Text type="secondary">({similarClimbs.length})</Text>}
</div>
}
key="similar-climbs"
>
{similarClimbs.length === 0 ? (
<Empty
description="No similar climbs found. Similar climbs are versions of this climb that use all the same holds plus additional holds."
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">
These climbs use all the holds from{' '}
<Text strong>{currentClimbName || 'this climb'}</Text> plus additional holds.
They may be versions created for larger board sizes.
</Text>
</div>
<Row gutter={[16, 16]}>
{similarClimbs.map((climb) => (
<Col xs={24} sm={12} md={8} lg={6} key={climb.uuid}>
<ClimbCard
climb={climb}
boardDetails={boardDetails}
actions={[<PlusCircleOutlined key="plus" />, <FireOutlined key="fire" />]}
/>
</Col>
))}
</Row>
</>
)}
</Panel>
</Collapse>
);
};

export default SimilarClimbs;
123 changes: 123 additions & 0 deletions app/lib/db/queries/climbs/similar-climbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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';
import { getBoardTables } from '@/lib/db/queries/util/table-select';

export interface SimilarClimb extends Climb {
totalHolds: number;
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.
*/
export const getSimilarClimbs = async (
params: ParsedBoardRouteParametersWithUuid,
limit: number = 10,
): Promise<SimilarClimb[]> => {
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
AND c.name NOT ILIKE 'Twister%'
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) => {
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) {
console.error('Error in getSimilarClimbs:', error);
throw error;
}
};