From 9973d4a8be125397e7cbb6ef9361ceddc2cfd748 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 22:37:55 +0000 Subject: [PATCH 1/3] feat: Count distinct climbs per grade on profile page - Add new GraphQL query `userProfileStats` that returns distinct climb counts per grade per board layout using server-side deduplication - Add backend resolver that groups ticks by climbUuid to count unique climbs rather than total ascents - Update profile page to use server-side stats for the Statistics Summary Card showing distinct climbs by grade - Charts still use frontend data with timeframe filtering This ensures that if a user climbed the same problem 5 times, it only counts as 1 climb for that grade, not 5 ascents. --- .../src/graphql/resolvers/ticks/queries.ts | 96 ++++++++++ packages/shared-schema/src/schema.ts | 27 +++ packages/shared-schema/src/types.ts | 22 +++ .../[user_id]/profile-page-content.tsx | 173 ++++++++---------- .../web/app/lib/graphql/operations/ticks.ts | 50 +++++ 5 files changed, 275 insertions(+), 93 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/ticks/queries.ts b/packages/backend/src/graphql/resolvers/ticks/queries.ts index ab0ad624..67d3399c 100644 --- a/packages/backend/src/graphql/resolvers/ticks/queries.ts +++ b/packages/backend/src/graphql/resolvers/ticks/queries.ts @@ -320,4 +320,100 @@ export const tickQueries = { hasMore: offset + items.length < totalCount, }; }, + + /** + * Get profile statistics with distinct climb counts per grade + * Groups by board type and layout, counting unique climbs per difficulty grade + */ + userProfileStats: async ( + _: unknown, + { userId }: { userId: string } + ): Promise<{ + totalDistinctClimbs: number; + layoutStats: Array<{ + layoutKey: string; + boardType: string; + layoutId: number | null; + distinctClimbCount: number; + gradeCounts: Array<{ grade: string; count: number }>; + }>; + }> => { + const boardTypes = ['kilter', 'tension'] as const; + const layoutStatsMap: Record; + gradeClimbs: Record>; // difficulty -> set of climbUuids + }> = {}; + const allClimbUuids = new Set(); + + for (const boardType of boardTypes) { + const climbsTable = getClimbsTable(boardType); + if (!climbsTable) continue; + + // Get all successful ticks (not attempts) with layoutId and difficulty + const results = await db + .select({ + climbUuid: dbSchema.boardseshTicks.climbUuid, + difficulty: dbSchema.boardseshTicks.difficulty, + layoutId: climbsTable.layoutId, + status: dbSchema.boardseshTicks.status, + }) + .from(dbSchema.boardseshTicks) + .leftJoin(climbsTable, eq(dbSchema.boardseshTicks.climbUuid, climbsTable.uuid)) + .where( + and( + eq(dbSchema.boardseshTicks.userId, userId), + eq(dbSchema.boardseshTicks.boardType, boardType) + ) + ); + + for (const row of results) { + // Only count successful ascents (not attempts) + if (row.status === 'attempt') continue; + + const layoutKey = `${boardType}-${row.layoutId ?? 'unknown'}`; + + if (!layoutStatsMap[layoutKey]) { + layoutStatsMap[layoutKey] = { + boardType, + layoutId: row.layoutId, + climbUuids: new Set(), + gradeClimbs: {}, + }; + } + + // Track distinct climbs per layout + layoutStatsMap[layoutKey].climbUuids.add(row.climbUuid); + allClimbUuids.add(row.climbUuid); + + // Track distinct climbs per grade per layout + if (row.difficulty !== null) { + if (!layoutStatsMap[layoutKey].gradeClimbs[row.difficulty]) { + layoutStatsMap[layoutKey].gradeClimbs[row.difficulty] = new Set(); + } + layoutStatsMap[layoutKey].gradeClimbs[row.difficulty].add(row.climbUuid); + } + } + } + + // Convert to response format + const layoutStats = Object.entries(layoutStatsMap).map(([layoutKey, stats]) => ({ + layoutKey, + boardType: stats.boardType, + layoutId: stats.layoutId, + distinctClimbCount: stats.climbUuids.size, + gradeCounts: Object.entries(stats.gradeClimbs) + .map(([difficulty, climbSet]) => ({ + grade: difficulty, + count: climbSet.size, + })) + .sort((a, b) => parseInt(a.grade) - parseInt(b.grade)), + })); + + return { + totalDistinctClimbs: allClimbUuids.size, + layoutStats, + }; + }, }; diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 6d5d325b..3c8131fe 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -321,6 +321,31 @@ export const typeDefs = /* GraphQL */ ` offset: Int } + # ============================================ + # Profile Statistics Types + # ============================================ + + # Count of distinct climbs for a specific grade + type GradeCount { + grade: String! + count: Int! + } + + # Statistics for a specific board layout + type LayoutStats { + layoutKey: String! + boardType: String! + layoutId: Int + distinctClimbCount: Int! + gradeCounts: [GradeCount!]! + } + + # Aggregated profile statistics across all boards + type ProfileStats { + totalDistinctClimbs: Int! + layoutStats: [LayoutStats!]! + } + # ============================================ # Playlist Types # ============================================ @@ -516,6 +541,8 @@ export const typeDefs = /* GraphQL */ ` userTicks(userId: ID!, boardType: String!): [Tick!]! # Get public ascent activity feed for a specific user (all boards, with climb details) userAscentsFeed(userId: ID!, input: AscentFeedInput): AscentFeedResult! + # Get profile statistics with distinct climb counts per grade (public) + userProfileStats(userId: ID!): ProfileStats! # ============================================ # Playlist Queries (require auth) diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 57756fef..86501cc5 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -257,6 +257,28 @@ export type GetTicksInput = { climbUuids?: string[]; }; +// ============================================ +// Profile Statistics Types +// ============================================ + +export type GradeCount = { + grade: string; + count: number; +}; + +export type LayoutStats = { + layoutKey: string; + boardType: string; + layoutId: number | null; + distinctClimbCount: number; + gradeCounts: GradeCount[]; +}; + +export type ProfileStats = { + totalDistinctClimbs: number; + layoutStats: LayoutStats[]; +}; + /** * Event types for GraphQL subscriptions * diff --git a/packages/web/app/crusher/[user_id]/profile-page-content.tsx b/packages/web/app/crusher/[user_id]/profile-page-content.tsx index 9796eb0b..fae88b97 100644 --- a/packages/web/app/crusher/[user_id]/profile-page-content.tsx +++ b/packages/web/app/crusher/[user_id]/profile-page-content.tsx @@ -15,7 +15,7 @@ import { message, Tooltip, } from 'antd'; -import { UserOutlined, InstagramOutlined, FireOutlined, TrophyOutlined } from '@ant-design/icons'; +import { UserOutlined, InstagramOutlined } from '@ant-design/icons'; import { useSession } from 'next-auth/react'; import Logo from '@/app/components/brand/logo'; import BackButton from '@/app/components/back-button'; @@ -27,7 +27,15 @@ import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import styles from './profile-page.module.css'; import type { ChartData } from './profile-stats-charts'; import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; -import { GET_USER_TICKS, type GetUserTicksQueryVariables, type GetUserTicksQueryResponse } from '@/app/lib/graphql/operations'; +import { + GET_USER_TICKS, + type GetUserTicksQueryVariables, + type GetUserTicksQueryResponse, + GET_USER_PROFILE_STATS, + type GetUserProfileStatsQueryVariables, + type GetUserProfileStatsQueryResponse, + type LayoutStats as GqlLayoutStats, +} from '@/app/lib/graphql/operations'; import { FONT_GRADE_COLORS, getGradeColorWithOpacity } from '@/app/lib/grade-colors'; dayjs.extend(isoWeek); @@ -67,6 +75,7 @@ interface LogbookEntry { status?: 'flash' | 'send' | 'attempt'; layoutId?: number | null; boardType?: string; + climbUuid?: string; } const difficultyMapping: Record = { @@ -179,6 +188,10 @@ export default function ProfilePageContent({ userId }: { userId: string }) { const [allBoardsTicks, setAllBoardsTicks] = useState>({}); const [loadingAggregated, setLoadingAggregated] = useState(false); + // State for server-side profile stats (distinct climb counts) + const [profileStats, setProfileStats] = useState(null); + const [loadingProfileStats, setLoadingProfileStats] = useState(false); + const isOwnProfile = session?.user?.id === userId; // Fetch profile data for the userId in the URL @@ -267,6 +280,7 @@ export default function ProfilePageContent({ userId }: { userId: string }) { status: tick.status, layoutId: tick.layoutId, boardType, + climbUuid: tick.climbUuid, })); }) ); @@ -280,6 +294,22 @@ export default function ProfilePageContent({ userId }: { userId: string }) { } }, [userId]); + // Fetch profile stats with distinct climb counts from server + const fetchProfileStats = useCallback(async () => { + setLoadingProfileStats(true); + try { + const client = createGraphQLHttpClient(null); + const variables: GetUserProfileStatsQueryVariables = { userId }; + const response = await client.request(GET_USER_PROFILE_STATS, variables); + setProfileStats(response.userProfileStats); + } catch (error) { + console.error('Error fetching profile stats:', error); + setProfileStats(null); + } finally { + setLoadingProfileStats(false); + } + }, [userId]); + // Fetch profile on mount useEffect(() => { fetchProfile(); @@ -290,6 +320,11 @@ export default function ProfilePageContent({ userId }: { userId: string }) { fetchAllBoardsTicks(); }, [fetchAllBoardsTicks]); + // Fetch profile stats on mount + useEffect(() => { + fetchProfileStats(); + }, [fetchProfileStats]); + // Fetch ticks when board selection changes useEffect(() => { if (selectedBoard) { @@ -341,8 +376,8 @@ export default function ProfilePageContent({ userId }: { userId: string }) { } }; - // Collect ascents by grade for each layout - const layoutGradeCounts: Record> = {}; + // Collect distinct climbs by grade for each layout (using Sets to deduplicate) + const layoutGradeClimbs: Record>> = {}; const allGrades = new Set(); const allLayouts = new Set(); @@ -351,15 +386,18 @@ export default function ProfilePageContent({ userId }: { userId: string }) { const filteredTicks = ticks.filter(filterByTimeframe); filteredTicks.forEach((entry) => { - // Only count ascents (not attempts) - if (entry.difficulty === null || entry.status === 'attempt') return; + // Only count ascents (not attempts) and must have a climbUuid + if (entry.difficulty === null || entry.status === 'attempt' || !entry.climbUuid) return; const grade = difficultyMapping[entry.difficulty]; if (grade) { const layoutKey = getLayoutKey(boardType, entry.layoutId); - if (!layoutGradeCounts[layoutKey]) { - layoutGradeCounts[layoutKey] = {}; + if (!layoutGradeClimbs[layoutKey]) { + layoutGradeClimbs[layoutKey] = {}; + } + if (!layoutGradeClimbs[layoutKey][grade]) { + layoutGradeClimbs[layoutKey][grade] = new Set(); } - layoutGradeCounts[layoutKey][grade] = (layoutGradeCounts[layoutKey][grade] || 0) + 1; + layoutGradeClimbs[layoutKey][grade].add(entry.climbUuid); allGrades.add(grade); allLayouts.add(layoutKey); } @@ -387,13 +425,13 @@ export default function ProfilePageContent({ userId }: { userId: string }) { return a.localeCompare(b); }); - // Create datasets for each layout + // Create datasets for each layout (using Set sizes for distinct climb counts) const datasets = sortedLayouts.map((layoutKey) => { const [boardType, layoutIdStr] = layoutKey.split('-'); const layoutId = layoutIdStr === 'unknown' ? null : parseInt(layoutIdStr, 10); return { label: getLayoutDisplayName(boardType, layoutId), - data: sortedGrades.map((grade) => layoutGradeCounts[layoutKey]?.[grade] || 0), + data: sortedGrades.map((grade) => layoutGradeClimbs[layoutKey]?.[grade]?.size || 0), backgroundColor: getLayoutColor(boardType, layoutId), }; }).filter((dataset) => dataset.data.some((value) => value > 0)); @@ -497,67 +535,34 @@ export default function ProfilePageContent({ userId }: { userId: string }) { return { chartDataBar, chartDataPie, chartDataWeeklyBar }; }, [filteredLogbook]); - // Calculate overall statistics summary (for the header) + // Calculate overall statistics summary using server-side distinct climb counts const statisticsSummary = useMemo(() => { - const layoutStats: Record }> = {}; - let totalAscents = 0; - let totalFlashes = 0; - let totalSends = 0; - - BOARD_TYPES.forEach((boardType) => { - const ticks = allBoardsTicks[boardType] || []; - - ticks.forEach((entry) => { - const layoutKey = getLayoutKey(boardType, entry.layoutId); - - if (!layoutStats[layoutKey]) { - layoutStats[layoutKey] = { count: 0, flashes: 0, sends: 0, attempts: 0, grades: {} }; - } - - // Only count successful ascents (not attempts) - if (entry.status !== 'attempt') { - layoutStats[layoutKey].count += 1; - totalAscents += 1; - - // Track flash vs send - if (entry.status === 'flash' || entry.tries === 1) { - layoutStats[layoutKey].flashes += 1; - totalFlashes += 1; - } else { - layoutStats[layoutKey].sends += 1; - totalSends += 1; - } + if (!profileStats) { + return { totalAscents: 0, layoutPercentages: [] }; + } - // Track grades for each layout - if (entry.difficulty !== null) { - const grade = difficultyMapping[entry.difficulty]; - if (grade) { - layoutStats[layoutKey].grades[grade] = (layoutStats[layoutKey].grades[grade] || 0) + 1; - } + const totalAscents = profileStats.totalDistinctClimbs; + + // Transform server data to display format + const layoutsWithExactPercentages = profileStats.layoutStats + .map((stats) => { + const exactPercentage = totalAscents > 0 ? (stats.distinctClimbCount / totalAscents) * 100 : 0; + // Convert grade counts array to Record format + const grades: Record = {}; + stats.gradeCounts.forEach(({ grade, count }) => { + const gradeName = difficultyMapping[parseInt(grade)]; + if (gradeName) { + grades[gradeName] = count; } - } else { - layoutStats[layoutKey].attempts += 1; - } - }); - }); - - // Calculate percentages using largest remainder method to ensure they sum to 100% - const layoutsWithExactPercentages = Object.entries(layoutStats) - .map(([layoutKey, stats]) => { - const [boardType, layoutIdStr] = layoutKey.split('-'); - const layoutId = layoutIdStr === 'unknown' ? null : parseInt(layoutIdStr, 10); - const exactPercentage = totalAscents > 0 ? (stats.count / totalAscents) * 100 : 0; + }); return { - layoutKey, - boardType, - layoutId, - displayName: getLayoutDisplayName(boardType, layoutId), - color: getLayoutColor(boardType, layoutId), - count: stats.count, - flashes: stats.flashes, - sends: stats.sends, - attempts: stats.attempts, - grades: stats.grades, + layoutKey: stats.layoutKey, + boardType: stats.boardType, + layoutId: stats.layoutId, + displayName: getLayoutDisplayName(stats.boardType, stats.layoutId), + color: getLayoutColor(stats.boardType, stats.layoutId), + count: stats.distinctClimbCount, + grades, exactPercentage, percentage: Math.floor(exactPercentage), remainder: exactPercentage - Math.floor(exactPercentage), @@ -581,11 +586,9 @@ export default function ProfilePageContent({ userId }: { userId: string }) { return { totalAscents, - totalFlashes, - totalSends, layoutPercentages, }; - }, [allBoardsTicks]); + }, [profileStats]); if (loading) { return ( @@ -678,32 +681,16 @@ export default function ProfilePageContent({ userId }: { userId: string }) { {/* Statistics Summary Card */} - {!loadingAggregated && statisticsSummary.totalAscents > 0 && ( + {!loadingProfileStats && statisticsSummary.totalAscents > 0 && ( - {/* Total Ascents Header */} + {/* Distinct Climbs Header */}
- Total Ascents + Distinct Climbs {statisticsSummary.totalAscents}
-
-
- -
- {statisticsSummary.totalFlashes} - Flashes -
-
-
- -
- {statisticsSummary.totalSends} - Sends -
-
-
{/* Board/Layout Percentage Bar */} @@ -712,7 +699,7 @@ export default function ProfilePageContent({ userId }: { userId: string }) { {statisticsSummary.layoutPercentages.map((layout) => (
{layout.displayName} - {layout.count} ascents + {layout.count} climbs
@@ -769,7 +756,7 @@ export default function ProfilePageContent({ userId }: { userId: string }) { .map(([grade, count]) => (
Date: Sat, 3 Jan 2026 22:42:55 +0000 Subject: [PATCH 2/3] refactor: Use SQL COUNT(DISTINCT) for profile stats query Replace JavaScript Set-based deduplication with SQL aggregation: - Use COUNT(DISTINCT climb_uuid) GROUP BY for grade counts - More efficient database-level aggregation instead of fetching all rows --- .../src/graphql/resolvers/ticks/queries.ts | 79 +++++++++++-------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/ticks/queries.ts b/packages/backend/src/graphql/resolvers/ticks/queries.ts index 67d3399c..e0469546 100644 --- a/packages/backend/src/graphql/resolvers/ticks/queries.ts +++ b/packages/backend/src/graphql/resolvers/ticks/queries.ts @@ -342,8 +342,7 @@ export const tickQueries = { const layoutStatsMap: Record; - gradeClimbs: Record>; // difficulty -> set of climbUuids + gradeCounts: Array<{ grade: string; count: number }>; }> = {}; const allClimbUuids = new Set(); @@ -351,65 +350,75 @@ export const tickQueries = { const climbsTable = getClimbsTable(boardType); if (!climbsTable) continue; - // Get all successful ticks (not attempts) with layoutId and difficulty - const results = await db + // Get distinct climb counts grouped by layoutId and difficulty using SQL aggregation + const gradeResults = await db .select({ - climbUuid: dbSchema.boardseshTicks.climbUuid, - difficulty: dbSchema.boardseshTicks.difficulty, layoutId: climbsTable.layoutId, - status: dbSchema.boardseshTicks.status, + difficulty: dbSchema.boardseshTicks.difficulty, + distinctCount: sql`count(distinct ${dbSchema.boardseshTicks.climbUuid})`.as('distinct_count'), }) .from(dbSchema.boardseshTicks) .leftJoin(climbsTable, eq(dbSchema.boardseshTicks.climbUuid, climbsTable.uuid)) .where( and( eq(dbSchema.boardseshTicks.userId, userId), - eq(dbSchema.boardseshTicks.boardType, boardType) + eq(dbSchema.boardseshTicks.boardType, boardType), + sql`${dbSchema.boardseshTicks.status} != 'attempt'` + ) + ) + .groupBy(climbsTable.layoutId, dbSchema.boardseshTicks.difficulty); + + // Also get all distinct climbUuids for total count (need to track across layouts) + const distinctClimbs = await db + .selectDistinct({ climbUuid: dbSchema.boardseshTicks.climbUuid }) + .from(dbSchema.boardseshTicks) + .where( + and( + eq(dbSchema.boardseshTicks.userId, userId), + eq(dbSchema.boardseshTicks.boardType, boardType), + sql`${dbSchema.boardseshTicks.status} != 'attempt'` ) ); - for (const row of results) { - // Only count successful ascents (not attempts) - if (row.status === 'attempt') continue; + // Add to total distinct climbs set + for (const row of distinctClimbs) { + allClimbUuids.add(row.climbUuid); + } + // Process grade results into layout stats + for (const row of gradeResults) { const layoutKey = `${boardType}-${row.layoutId ?? 'unknown'}`; if (!layoutStatsMap[layoutKey]) { layoutStatsMap[layoutKey] = { boardType, layoutId: row.layoutId, - climbUuids: new Set(), - gradeClimbs: {}, + gradeCounts: [], }; } - // Track distinct climbs per layout - layoutStatsMap[layoutKey].climbUuids.add(row.climbUuid); - allClimbUuids.add(row.climbUuid); - - // Track distinct climbs per grade per layout if (row.difficulty !== null) { - if (!layoutStatsMap[layoutKey].gradeClimbs[row.difficulty]) { - layoutStatsMap[layoutKey].gradeClimbs[row.difficulty] = new Set(); - } - layoutStatsMap[layoutKey].gradeClimbs[row.difficulty].add(row.climbUuid); + layoutStatsMap[layoutKey].gradeCounts.push({ + grade: String(row.difficulty), + count: Number(row.distinctCount), + }); } } } - // Convert to response format - const layoutStats = Object.entries(layoutStatsMap).map(([layoutKey, stats]) => ({ - layoutKey, - boardType: stats.boardType, - layoutId: stats.layoutId, - distinctClimbCount: stats.climbUuids.size, - gradeCounts: Object.entries(stats.gradeClimbs) - .map(([difficulty, climbSet]) => ({ - grade: difficulty, - count: climbSet.size, - })) - .sort((a, b) => parseInt(a.grade) - parseInt(b.grade)), - })); + // Convert to response format with sorted grade counts + const layoutStats = Object.entries(layoutStatsMap).map(([layoutKey, stats]) => { + // Calculate total distinct climbs for this layout by summing grade counts + const distinctClimbCount = stats.gradeCounts.reduce((sum, gc) => sum + gc.count, 0); + + return { + layoutKey, + boardType: stats.boardType, + layoutId: stats.layoutId, + distinctClimbCount, + gradeCounts: stats.gradeCounts.sort((a, b) => parseInt(a.grade) - parseInt(b.grade)), + }; + }); return { totalDistinctClimbs: allClimbUuids.size, From 5c80aa7a781f321de4ef150e903e0a379f4f8844 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 22:50:13 +0000 Subject: [PATCH 3/3] fix: Address code review issues for profile stats - Rename "Routes by Angle" to "Ascents by Angle" with distinct counts - Count distinct climbs per angle using climbUuid+angle as unique key - Add userId validation in backend resolver (return empty for invalid) - Parallelize database queries with Promise.all for better performance - Add NaN check for parseInt when parsing grade difficulty - Remove unused GqlLayoutStats import - Add climbUuid to logbook entries for angle chart deduplication --- .../src/graphql/resolvers/ticks/queries.ts | 77 +++++++++++-------- .../[user_id]/profile-page-content.tsx | 36 +++++---- 2 files changed, 70 insertions(+), 43 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/ticks/queries.ts b/packages/backend/src/graphql/resolvers/ticks/queries.ts index e0469546..0b58268a 100644 --- a/packages/backend/src/graphql/resolvers/ticks/queries.ts +++ b/packages/backend/src/graphql/resolvers/ticks/queries.ts @@ -338,6 +338,11 @@ export const tickQueries = { gradeCounts: Array<{ grade: string; count: number }>; }>; }> => { + // Validate userId + if (!userId || typeof userId !== 'string' || userId.trim() === '') { + return { totalDistinctClimbs: 0, layoutStats: [] }; + } + const boardTypes = ['kilter', 'tension'] as const; const layoutStatsMap: Record = {}; const allClimbUuids = new Set(); - for (const boardType of boardTypes) { + // Helper function to fetch stats for a single board type + const fetchBoardStats = async (boardType: 'kilter' | 'tension') => { const climbsTable = getClimbsTable(boardType); - if (!climbsTable) continue; - - // Get distinct climb counts grouped by layoutId and difficulty using SQL aggregation - const gradeResults = await db - .select({ - layoutId: climbsTable.layoutId, - difficulty: dbSchema.boardseshTicks.difficulty, - distinctCount: sql`count(distinct ${dbSchema.boardseshTicks.climbUuid})`.as('distinct_count'), - }) - .from(dbSchema.boardseshTicks) - .leftJoin(climbsTable, eq(dbSchema.boardseshTicks.climbUuid, climbsTable.uuid)) - .where( - and( - eq(dbSchema.boardseshTicks.userId, userId), - eq(dbSchema.boardseshTicks.boardType, boardType), - sql`${dbSchema.boardseshTicks.status} != 'attempt'` + if (!climbsTable) return { gradeResults: [], distinctClimbs: [], boardType }; + + // Run both queries in parallel for this board type + const [gradeResults, distinctClimbs] = await Promise.all([ + // Get distinct climb counts grouped by layoutId and difficulty using SQL aggregation + db + .select({ + layoutId: climbsTable.layoutId, + difficulty: dbSchema.boardseshTicks.difficulty, + distinctCount: sql`count(distinct ${dbSchema.boardseshTicks.climbUuid})`.as('distinct_count'), + }) + .from(dbSchema.boardseshTicks) + .leftJoin(climbsTable, eq(dbSchema.boardseshTicks.climbUuid, climbsTable.uuid)) + .where( + and( + eq(dbSchema.boardseshTicks.userId, userId), + eq(dbSchema.boardseshTicks.boardType, boardType), + sql`${dbSchema.boardseshTicks.status} != 'attempt'` + ) ) - ) - .groupBy(climbsTable.layoutId, dbSchema.boardseshTicks.difficulty); + .groupBy(climbsTable.layoutId, dbSchema.boardseshTicks.difficulty), + + // Get all distinct climbUuids for total count + db + .selectDistinct({ climbUuid: dbSchema.boardseshTicks.climbUuid }) + .from(dbSchema.boardseshTicks) + .where( + and( + eq(dbSchema.boardseshTicks.userId, userId), + eq(dbSchema.boardseshTicks.boardType, boardType), + sql`${dbSchema.boardseshTicks.status} != 'attempt'` + ) + ), + ]); + + return { gradeResults, distinctClimbs, boardType }; + }; - // Also get all distinct climbUuids for total count (need to track across layouts) - const distinctClimbs = await db - .selectDistinct({ climbUuid: dbSchema.boardseshTicks.climbUuid }) - .from(dbSchema.boardseshTicks) - .where( - and( - eq(dbSchema.boardseshTicks.userId, userId), - eq(dbSchema.boardseshTicks.boardType, boardType), - sql`${dbSchema.boardseshTicks.status} != 'attempt'` - ) - ); + // Fetch stats for all board types in parallel + const boardResults = await Promise.all(boardTypes.map(fetchBoardStats)); + // Process results from all boards + for (const { gradeResults, distinctClimbs, boardType } of boardResults) { // Add to total distinct climbs set for (const row of distinctClimbs) { allClimbUuids.add(row.climbUuid); diff --git a/packages/web/app/crusher/[user_id]/profile-page-content.tsx b/packages/web/app/crusher/[user_id]/profile-page-content.tsx index fae88b97..7db589b4 100644 --- a/packages/web/app/crusher/[user_id]/profile-page-content.tsx +++ b/packages/web/app/crusher/[user_id]/profile-page-content.tsx @@ -34,7 +34,6 @@ import { GET_USER_PROFILE_STATS, type GetUserProfileStatsQueryVariables, type GetUserProfileStatsQueryResponse, - type LayoutStats as GqlLayoutStats, } from '@/app/lib/graphql/operations'; import { FONT_GRADE_COLORS, getGradeColorWithOpacity } from '@/app/lib/grade-colors'; @@ -244,6 +243,7 @@ export default function ProfilePageContent({ userId }: { userId: string }) { tries: tick.attemptCount, angle: tick.angle, status: tick.status, + climbUuid: tick.climbUuid, })); setLogbook(entries); @@ -479,19 +479,26 @@ export default function ProfilePageContent({ userId }: { userId: string }) { ], }; - // Pie chart - Routes by Angle - const angles = filteredLogbook.reduce((acc: Record, entry) => { + // Pie chart - Ascents by Angle (distinct climbs per angle, using climbUuid+angle as key) + const angleClimbs: Record> = {}; + filteredLogbook.forEach((entry) => { + // Only count successful ascents with a climbUuid + if (entry.status === 'attempt' || !entry.climbUuid) return; const angle = `${entry.angle}°`; - acc[angle] = (acc[angle] || 0) + 1; - return acc; - }, {}); + if (!angleClimbs[angle]) { + angleClimbs[angle] = new Set(); + } + // Use climbUuid+angle as the unique key (same climb at different angles counts separately) + angleClimbs[angle].add(`${entry.climbUuid}-${entry.angle}`); + }); + const angleLabels = Object.keys(angleClimbs).sort((a, b) => parseInt(a) - parseInt(b)); const chartDataPie: ChartData = { - labels: Object.keys(angles), + labels: angleLabels, datasets: [ { - label: 'Routes by Angle', - data: Object.values(angles), - backgroundColor: Object.keys(angles).map((_, index) => angleColors[index] || 'rgba(200,200,200,0.7)'), + label: 'Ascents by Angle', + data: angleLabels.map((angle) => angleClimbs[angle]?.size || 0), + backgroundColor: angleLabels.map((_, index) => angleColors[index] || 'rgba(200,200,200,0.7)'), }, ], }; @@ -550,9 +557,12 @@ export default function ProfilePageContent({ userId }: { userId: string }) { // Convert grade counts array to Record format const grades: Record = {}; stats.gradeCounts.forEach(({ grade, count }) => { - const gradeName = difficultyMapping[parseInt(grade)]; - if (gradeName) { - grades[gradeName] = count; + const difficultyNum = parseInt(grade, 10); + if (!isNaN(difficultyNum)) { + const gradeName = difficultyMapping[difficultyNum]; + if (gradeName) { + grades[gradeName] = count; + } } }); return {