From 60b408a5bbff1ba96a40b8dc1f4ceb9041940a78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 08:31:35 +0000 Subject: [PATCH 1/3] feat(activity-feed): Group climb attempts by climb and day Groups multiple attempts/ascents of the same climb on the same day into a single card showing a summary (e.g., "3 attempts" or "Sent, 2 attempts"). The thumbnail still navigates to the climb info page when clicked. --- .../components/activity-feed/ascents-feed.tsx | 213 ++++++++++++------ 1 file changed, 148 insertions(+), 65 deletions(-) diff --git a/packages/web/app/components/activity-feed/ascents-feed.tsx b/packages/web/app/components/activity-feed/ascents-feed.tsx index 7157c24d..5598b198 100644 --- a/packages/web/app/components/activity-feed/ascents-feed.tsx +++ b/packages/web/app/components/activity-feed/ascents-feed.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { Card, Flex, Tag, Typography, Rate, Empty, Spin, Button } from 'antd'; import { CheckCircleOutlined, @@ -22,7 +22,29 @@ import styles from './ascents-feed.module.css'; dayjs.extend(relativeTime); -const { Text, Title } = Typography; +const { Text } = Typography; + +// Type for grouped climb attempts +interface GroupedClimbAttempts { + key: string; // climbUuid + day + climbUuid: string; + climbName: string; + setterUsername: string | null; + boardType: string; + layoutId: number | null; + angle: number; + isMirror: boolean; + frames: string | null; + difficultyName: string | null; + isBenchmark: boolean; + date: string; // The day of the attempts + items: AscentFeedItem[]; + flashCount: number; + sendCount: number; + attemptCount: number; + bestQuality: number | null; + latestComment: string | null; +} interface AscentsFeedProps { userId: string; @@ -44,59 +66,124 @@ const getLayoutDisplayName = (boardType: string, layoutId: number | null): strin return layoutNames[key] || `${boardType.charAt(0).toUpperCase() + boardType.slice(1)}`; }; -// Status icon and color mapping -const getStatusIcon = (status: 'flash' | 'send' | 'attempt') => { - switch (status) { - case 'flash': - return ; - case 'send': - return ; - case 'attempt': - return ; +// Function to group items by climb and day +const groupItemsByClimbAndDay = (items: AscentFeedItem[]): GroupedClimbAttempts[] => { + const groupMap = new Map(); + + for (const item of items) { + const day = dayjs(item.climbedAt).format('YYYY-MM-DD'); + const key = `${item.climbUuid}-${day}`; + + if (!groupMap.has(key)) { + groupMap.set(key, { + key, + climbUuid: item.climbUuid, + climbName: item.climbName, + setterUsername: item.setterUsername, + boardType: item.boardType, + layoutId: item.layoutId, + angle: item.angle, + isMirror: item.isMirror, + frames: item.frames, + difficultyName: item.difficultyName, + isBenchmark: item.isBenchmark, + date: day, + items: [], + flashCount: 0, + sendCount: 0, + attemptCount: 0, + bestQuality: null, + latestComment: null, + }); + } + + const group = groupMap.get(key)!; + group.items.push(item); + + // Update counts + if (item.status === 'flash') { + group.flashCount++; + } else if (item.status === 'send') { + group.sendCount++; + } else { + group.attemptCount++; + } + + // Track best quality rating + if (item.quality !== null) { + if (group.bestQuality === null || item.quality > group.bestQuality) { + group.bestQuality = item.quality; + } + } + + // Track latest comment (prefer non-empty) + if (item.comment && !group.latestComment) { + group.latestComment = item.comment; + } } + + // Convert to array and sort by the latest item date + return Array.from(groupMap.values()).sort((a, b) => { + const aLatest = Math.max(...a.items.map((i) => new Date(i.climbedAt).getTime())); + const bLatest = Math.max(...b.items.map((i) => new Date(i.climbedAt).getTime())); + return bLatest - aLatest; + }); }; -const getStatusColor = (status: 'flash' | 'send' | 'attempt') => { - switch (status) { - case 'flash': - return 'gold'; - case 'send': - return 'green'; - case 'attempt': - return 'default'; +// Generate status summary for grouped attempts +const getGroupStatusSummary = (group: GroupedClimbAttempts): { text: string; icon: React.ReactNode; color: string } => { + const parts: string[] = []; + + // Determine primary status (best result first) + if (group.flashCount > 0) { + parts.push(group.flashCount === 1 ? 'Flashed' : `${group.flashCount} flashes`); + } + if (group.sendCount > 0) { + parts.push(group.sendCount === 1 ? 'Sent' : `${group.sendCount} sends`); + } + if (group.attemptCount > 0) { + parts.push(group.attemptCount === 1 ? '1 attempt' : `${group.attemptCount} attempts`); } -}; -const getStatusText = (status: 'flash' | 'send' | 'attempt') => { - switch (status) { - case 'flash': - return 'Flashed'; - case 'send': - return 'Sent'; - case 'attempt': - return 'Attempted'; + // Determine color and icon based on best result + let icon: React.ReactNode; + let color: string; + if (group.flashCount > 0) { + icon = ; + color = 'gold'; + } else if (group.sendCount > 0) { + icon = ; + color = 'green'; + } else { + icon = ; + color = 'default'; } + + return { text: parts.join(', '), icon, color }; }; -const FeedItem: React.FC<{ item: AscentFeedItem }> = ({ item }) => { - const timeAgo = dayjs(item.climbedAt).fromNow(); - const boardDisplay = getLayoutDisplayName(item.boardType, item.layoutId); - const statusText = getStatusText(item.status); - const isSuccess = item.status === 'flash' || item.status === 'send'; +const GroupedFeedItem: React.FC<{ group: GroupedClimbAttempts }> = ({ group }) => { + const latestItem = group.items.reduce((latest, item) => + new Date(item.climbedAt) > new Date(latest.climbedAt) ? item : latest + ); + const timeAgo = dayjs(latestItem.climbedAt).fromNow(); + const boardDisplay = getLayoutDisplayName(group.boardType, group.layoutId); + const statusSummary = getGroupStatusSummary(group); + const hasSuccess = group.flashCount > 0 || group.sendCount > 0; return ( {/* Thumbnail */} - {item.frames && item.layoutId && ( + {group.frames && group.layoutId && ( )} @@ -106,14 +193,14 @@ const FeedItem: React.FC<{ item: AscentFeedItem }> = ({ item }) => { - {statusText} + {statusSummary.text} - {item.climbName} + {group.climbName} @@ -123,39 +210,32 @@ const FeedItem: React.FC<{ item: AscentFeedItem }> = ({ item }) => { {/* Climb details */} - {item.difficultyName && ( - {item.difficultyName} + {group.difficultyName && ( + {group.difficultyName} )} - }>{item.angle}° + }>{group.angle}° {boardDisplay} - {item.isMirror && Mirrored} - {item.isBenchmark && Benchmark} + {group.isMirror && Mirrored} + {group.isBenchmark && Benchmark} - {/* Attempts count for sends */} - {item.status === 'send' && item.attemptCount > 1 && ( - - {item.attemptCount} attempts - - )} - {/* Rating for successful sends */} - {isSuccess && item.quality && ( - + {hasSuccess && group.bestQuality && ( + )} {/* Setter info */} - {item.setterUsername && ( + {group.setterUsername && ( - Set by {item.setterUsername} + Set by {group.setterUsername} )} {/* Comment */} - {item.comment && ( - {item.comment} + {group.latestComment && ( + {group.latestComment} )} @@ -242,6 +322,9 @@ export const AscentsFeed: React.FC = ({ userId, pageSize = 10 ); } + // Group items by climb and day + const groupedItems = useMemo(() => groupItemsByClimbAndDay(items), [items]); + if (items.length === 0) { return ( = ({ userId, pageSize = 10 return (
- {items.map((item) => ( - + {groupedItems.map((group) => ( + ))} From 1dd5a1b4c831813c28438290a770cbdb3d06adc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 08:35:57 +0000 Subject: [PATCH 2/3] feat(activity-feed): Group climb attempts by day in profile activity feed - Add new GraphQL type GroupedAscentFeedItem and query userGroupedAscentsFeed - Backend groups attempts by climbUuid + date with counts for flash/send/attempt - Track best quality rating and latest comment per group - Frontend displays grouped entries with combined status summary - Thumbnail click navigates to climb info page (existing behavior) Examples of grouped display: - "3 attempts" - multiple failed attempts - "6 sends" - multiple successful sends - "Flashed, 2 attempts" - one flash with additional attempts - "3 sends, 3 attempts" - mixed results --- .../src/graphql/resolvers/ticks/queries.ts | 190 ++++++++++++++++++ packages/shared-schema/src/schema.ts | 37 ++++ .../components/activity-feed/ascents-feed.tsx | 126 ++---------- .../web/app/lib/graphql/operations/ticks.ts | 91 +++++++++ 4 files changed, 337 insertions(+), 107 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/ticks/queries.ts b/packages/backend/src/graphql/resolvers/ticks/queries.ts index 8915b1a1..202fc135 100644 --- a/packages/backend/src/graphql/resolvers/ticks/queries.ts +++ b/packages/backend/src/graphql/resolvers/ticks/queries.ts @@ -208,6 +208,196 @@ export const tickQueries = { }; }, + /** + * Get ascent activity feed grouped by climb and day (public query) + * Groups multiple attempts on the same climb on the same day into a single entry + */ + userGroupedAscentsFeed: async ( + _: unknown, + { userId, input }: { userId: string; input?: { limit?: number; offset?: number } } + ): Promise<{ + groups: unknown[]; + totalCount: number; + hasMore: boolean; + }> => { + // Validate and set defaults + const validatedInput = validateInput(AscentFeedInputSchema, input || {}, 'input'); + const limit = validatedInput.limit ?? 20; + const offset = validatedInput.offset ?? 0; + + // Fetch all ticks with climb and grade data (we need all to group properly) + // For performance, we fetch a larger batch and then group/paginate + const results = await db + .select({ + tick: dbSchema.boardseshTicks, + climbName: dbSchema.boardClimbs.name, + setterUsername: dbSchema.boardClimbs.setterUsername, + layoutId: dbSchema.boardClimbs.layoutId, + frames: dbSchema.boardClimbs.frames, + difficultyName: dbSchema.boardDifficultyGrades.boulderName, + }) + .from(dbSchema.boardseshTicks) + .leftJoin( + dbSchema.boardClimbs, + and( + eq(dbSchema.boardseshTicks.climbUuid, dbSchema.boardClimbs.uuid), + eq(dbSchema.boardseshTicks.boardType, dbSchema.boardClimbs.boardType) + ) + ) + .leftJoin( + dbSchema.boardDifficultyGrades, + and( + eq(dbSchema.boardseshTicks.difficulty, dbSchema.boardDifficultyGrades.difficulty), + eq(dbSchema.boardseshTicks.boardType, dbSchema.boardDifficultyGrades.boardType) + ) + ) + .where(eq(dbSchema.boardseshTicks.userId, userId)) + .orderBy(desc(dbSchema.boardseshTicks.climbedAt)); + + // Group items by climbUuid and day + type AscentItem = { + uuid: string; + climbUuid: string; + climbName: string; + setterUsername: string | null; + boardType: string; + layoutId: number | null; + angle: number; + isMirror: boolean; + status: string; + attemptCount: number; + quality: number | null; + difficulty: number | null; + difficultyName: string | null; + isBenchmark: boolean; + comment: string; + climbedAt: string; + frames: string | null; + }; + + type GroupedAscent = { + key: string; + climbUuid: string; + climbName: string; + setterUsername: string | null; + boardType: string; + layoutId: number | null; + angle: number; + isMirror: boolean; + frames: string | null; + difficultyName: string | null; + isBenchmark: boolean; + date: string; + items: AscentItem[]; + flashCount: number; + sendCount: number; + attemptCount: number; + bestQuality: number | null; + latestComment: string | null; + latestClimbedAt: string; + }; + + const groupMap = new Map(); + + for (const { tick, climbName, setterUsername, layoutId, frames, difficultyName } of results) { + // Extract date from climbedAt (YYYY-MM-DD) + const date = tick.climbedAt.split('T')[0]; + const key = `${tick.climbUuid}-${date}`; + + const item: AscentItem = { + uuid: tick.uuid, + climbUuid: tick.climbUuid, + climbName: climbName || 'Unknown Climb', + setterUsername, + boardType: tick.boardType, + layoutId, + angle: tick.angle, + isMirror: tick.isMirror, + status: tick.status, + attemptCount: tick.attemptCount, + quality: tick.quality, + difficulty: tick.difficulty, + difficultyName, + isBenchmark: tick.isBenchmark, + comment: tick.comment || '', + climbedAt: tick.climbedAt, + frames, + }; + + if (!groupMap.has(key)) { + groupMap.set(key, { + key, + climbUuid: tick.climbUuid, + climbName: climbName || 'Unknown Climb', + setterUsername, + boardType: tick.boardType, + layoutId, + angle: tick.angle, + isMirror: tick.isMirror, + frames, + difficultyName, + isBenchmark: tick.isBenchmark, + date, + items: [], + flashCount: 0, + sendCount: 0, + attemptCount: 0, + bestQuality: null, + latestComment: null, + latestClimbedAt: tick.climbedAt, + }); + } + + const group = groupMap.get(key)!; + group.items.push(item); + + // Update counts + if (tick.status === 'flash') { + group.flashCount++; + } else if (tick.status === 'send') { + group.sendCount++; + } else { + group.attemptCount++; + } + + // Track best quality rating + if (tick.quality !== null) { + if (group.bestQuality === null || tick.quality > group.bestQuality) { + group.bestQuality = tick.quality; + } + } + + // Track latest comment (prefer non-empty) + if (tick.comment && !group.latestComment) { + group.latestComment = tick.comment; + } + + // Track latest climbedAt for sorting + if (tick.climbedAt > group.latestClimbedAt) { + group.latestClimbedAt = tick.climbedAt; + } + } + + // Convert to array and sort by latest activity + const allGroups = Array.from(groupMap.values()).sort( + (a, b) => new Date(b.latestClimbedAt).getTime() - new Date(a.latestClimbedAt).getTime() + ); + + const totalCount = allGroups.length; + + // Apply pagination to groups + const paginatedGroups = allGroups.slice(offset, offset + limit); + + // Remove latestClimbedAt from final output (internal use only) + const groups = paginatedGroups.map(({ latestClimbedAt, ...rest }) => rest); + + return { + groups, + totalCount, + hasMore: offset + groups.length < totalCount, + }; + }, + /** * Get profile statistics with distinct climb counts per grade * Groups by board type and layout, counting unique climbs per difficulty grade diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 3c8131fe..c5ba8f37 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -316,6 +316,41 @@ export const typeDefs = /* GraphQL */ ` hasMore: Boolean! } + # Grouped climb attempts for a single climb on a single day + type GroupedAscentFeedItem { + # Unique key for this group (climbUuid-date) + key: String! + climbUuid: String! + climbName: String! + setterUsername: String + boardType: String! + layoutId: Int + angle: Int! + isMirror: Boolean! + # Climb display data for thumbnails + frames: String + difficultyName: String + isBenchmark: Boolean! + # The date of the attempts (YYYY-MM-DD) + date: String! + # Counts by status + flashCount: Int! + sendCount: Int! + attemptCount: Int! + # Best quality rating from any attempt in this group + bestQuality: Int + # Latest comment from any attempt in this group + latestComment: String + # Individual items in this group (for detailed view if needed) + items: [AscentFeedItem!]! + } + + type GroupedAscentFeedResult { + groups: [GroupedAscentFeedItem!]! + totalCount: Int! + hasMore: Boolean! + } + input AscentFeedInput { limit: Int offset: Int @@ -541,6 +576,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 public ascent activity feed grouped by climb and day + userGroupedAscentsFeed(userId: ID!, input: AscentFeedInput): GroupedAscentFeedResult! # Get profile statistics with distinct climb counts per grade (public) userProfileStats(userId: ID!): ProfileStats! diff --git a/packages/web/app/components/activity-feed/ascents-feed.tsx b/packages/web/app/components/activity-feed/ascents-feed.tsx index 5598b198..c3ec8ea7 100644 --- a/packages/web/app/components/activity-feed/ascents-feed.tsx +++ b/packages/web/app/components/activity-feed/ascents-feed.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; import { Card, Flex, Tag, Typography, Rate, Empty, Spin, Button } from 'antd'; import { CheckCircleOutlined, @@ -12,10 +12,10 @@ import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; import { - GET_USER_ASCENTS_FEED, - type AscentFeedItem, - type GetUserAscentsFeedQueryVariables, - type GetUserAscentsFeedQueryResponse, + GET_USER_GROUPED_ASCENTS_FEED, + type GroupedAscentFeedItem, + type GetUserGroupedAscentsFeedQueryVariables, + type GetUserGroupedAscentsFeedQueryResponse, } from '@/app/lib/graphql/operations'; import AscentThumbnail from './ascent-thumbnail'; import styles from './ascents-feed.module.css'; @@ -24,28 +24,6 @@ dayjs.extend(relativeTime); const { Text } = Typography; -// Type for grouped climb attempts -interface GroupedClimbAttempts { - key: string; // climbUuid + day - climbUuid: string; - climbName: string; - setterUsername: string | null; - boardType: string; - layoutId: number | null; - angle: number; - isMirror: boolean; - frames: string | null; - difficultyName: string | null; - isBenchmark: boolean; - date: string; // The day of the attempts - items: AscentFeedItem[]; - flashCount: number; - sendCount: number; - attemptCount: number; - bestQuality: number | null; - latestComment: string | null; -} - interface AscentsFeedProps { userId: string; pageSize?: number; @@ -66,72 +44,8 @@ const getLayoutDisplayName = (boardType: string, layoutId: number | null): strin return layoutNames[key] || `${boardType.charAt(0).toUpperCase() + boardType.slice(1)}`; }; -// Function to group items by climb and day -const groupItemsByClimbAndDay = (items: AscentFeedItem[]): GroupedClimbAttempts[] => { - const groupMap = new Map(); - - for (const item of items) { - const day = dayjs(item.climbedAt).format('YYYY-MM-DD'); - const key = `${item.climbUuid}-${day}`; - - if (!groupMap.has(key)) { - groupMap.set(key, { - key, - climbUuid: item.climbUuid, - climbName: item.climbName, - setterUsername: item.setterUsername, - boardType: item.boardType, - layoutId: item.layoutId, - angle: item.angle, - isMirror: item.isMirror, - frames: item.frames, - difficultyName: item.difficultyName, - isBenchmark: item.isBenchmark, - date: day, - items: [], - flashCount: 0, - sendCount: 0, - attemptCount: 0, - bestQuality: null, - latestComment: null, - }); - } - - const group = groupMap.get(key)!; - group.items.push(item); - - // Update counts - if (item.status === 'flash') { - group.flashCount++; - } else if (item.status === 'send') { - group.sendCount++; - } else { - group.attemptCount++; - } - - // Track best quality rating - if (item.quality !== null) { - if (group.bestQuality === null || item.quality > group.bestQuality) { - group.bestQuality = item.quality; - } - } - - // Track latest comment (prefer non-empty) - if (item.comment && !group.latestComment) { - group.latestComment = item.comment; - } - } - - // Convert to array and sort by the latest item date - return Array.from(groupMap.values()).sort((a, b) => { - const aLatest = Math.max(...a.items.map((i) => new Date(i.climbedAt).getTime())); - const bLatest = Math.max(...b.items.map((i) => new Date(i.climbedAt).getTime())); - return bLatest - aLatest; - }); -}; - // Generate status summary for grouped attempts -const getGroupStatusSummary = (group: GroupedClimbAttempts): { text: string; icon: React.ReactNode; color: string } => { +const getGroupStatusSummary = (group: GroupedAscentFeedItem): { text: string; icon: React.ReactNode; color: string } => { const parts: string[] = []; // Determine primary status (best result first) @@ -162,7 +76,8 @@ const getGroupStatusSummary = (group: GroupedClimbAttempts): { text: string; ico return { text: parts.join(', '), icon, color }; }; -const GroupedFeedItem: React.FC<{ group: GroupedClimbAttempts }> = ({ group }) => { +const GroupedFeedItem: React.FC<{ group: GroupedAscentFeedItem }> = ({ group }) => { + // Get the latest item's climbedAt for time display const latestItem = group.items.reduce((latest, item) => new Date(item.climbedAt) > new Date(latest.climbedAt) ? item : latest ); @@ -244,7 +159,7 @@ const GroupedFeedItem: React.FC<{ group: GroupedClimbAttempts }> = ({ group }) = }; export const AscentsFeed: React.FC = ({ userId, pageSize = 10 }) => { - const [items, setItems] = useState([]); + const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); @@ -262,22 +177,22 @@ export const AscentsFeed: React.FC = ({ userId, pageSize = 10 } const client = createGraphQLHttpClient(null); - const variables: GetUserAscentsFeedQueryVariables = { + const variables: GetUserGroupedAscentsFeedQueryVariables = { userId, input: { limit: pageSize, offset }, }; - const response = await client.request( - GET_USER_ASCENTS_FEED, + const response = await client.request( + GET_USER_GROUPED_ASCENTS_FEED, variables ); - const { items: newItems, hasMore: more, totalCount: total } = response.userAscentsFeed; + const { groups: newGroups, hasMore: more, totalCount: total } = response.userGroupedAscentsFeed; if (append) { - setItems((prev) => [...prev, ...newItems]); + setGroups((prev) => [...prev, ...newGroups]); } else { - setItems(newItems); + setGroups(newGroups); } setHasMore(more); setTotalCount(total); @@ -301,7 +216,7 @@ export const AscentsFeed: React.FC = ({ userId, pageSize = 10 // Load more when button clicked const handleLoadMore = () => { if (!loadingMore && hasMore) { - fetchFeed(items.length, true); + fetchFeed(groups.length, true); } }; @@ -322,10 +237,7 @@ export const AscentsFeed: React.FC = ({ userId, pageSize = 10 ); } - // Group items by climb and day - const groupedItems = useMemo(() => groupItemsByClimbAndDay(items), [items]); - - if (items.length === 0) { + if (groups.length === 0) { return ( = ({ userId, pageSize = 10 return (
- {groupedItems.map((group) => ( + {groups.map((group) => ( ))} @@ -350,7 +262,7 @@ export const AscentsFeed: React.FC = ({ userId, pageSize = 10 type="default" block > - Load more ({items.length} of {totalCount}) + Load more ({groups.length} of {totalCount})
)} diff --git a/packages/web/app/lib/graphql/operations/ticks.ts b/packages/web/app/lib/graphql/operations/ticks.ts index 1133691d..47594194 100644 --- a/packages/web/app/lib/graphql/operations/ticks.ts +++ b/packages/web/app/lib/graphql/operations/ticks.ts @@ -182,6 +182,97 @@ export interface GetUserAscentsFeedQueryResponse { }; } +// ============================================ +// Grouped Activity Feed Operations +// ============================================ + +export const GET_USER_GROUPED_ASCENTS_FEED = gql` + query GetUserGroupedAscentsFeed($userId: ID!, $input: AscentFeedInput) { + userGroupedAscentsFeed(userId: $userId, input: $input) { + groups { + key + climbUuid + climbName + setterUsername + boardType + layoutId + angle + isMirror + frames + difficultyName + isBenchmark + date + flashCount + sendCount + attemptCount + bestQuality + latestComment + items { + uuid + climbUuid + climbName + setterUsername + boardType + layoutId + angle + isMirror + status + attemptCount + quality + difficulty + difficultyName + isBenchmark + comment + climbedAt + frames + } + } + totalCount + hasMore + } + } +`; + +// Type for grouped ascent feed item +export interface GroupedAscentFeedItem { + key: string; + climbUuid: string; + climbName: string; + setterUsername: string | null; + boardType: string; + layoutId: number | null; + angle: number; + isMirror: boolean; + frames: string | null; + difficultyName: string | null; + isBenchmark: boolean; + date: string; + flashCount: number; + sendCount: number; + attemptCount: number; + bestQuality: number | null; + latestComment: string | null; + items: AscentFeedItem[]; +} + +// Type for the grouped feed query variables +export interface GetUserGroupedAscentsFeedQueryVariables { + userId: string; + input?: { + limit?: number; + offset?: number; + }; +} + +// Type for the grouped feed query response +export interface GetUserGroupedAscentsFeedQueryResponse { + userGroupedAscentsFeed: { + groups: GroupedAscentFeedItem[]; + totalCount: number; + hasMore: boolean; + }; +} + // ============================================ // Profile Statistics Operations // ============================================ From 775f227636a6454786a8ddcbaffd596e67e2a821 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 08:45:39 +0000 Subject: [PATCH 3/3] perf(activity-feed): Limit fetch to 500 most recent ticks Prevents memory issues for users with thousands of logged climbs by capping the fetch to 500 most recent entries before grouping. --- packages/backend/src/graphql/resolvers/ticks/queries.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/ticks/queries.ts b/packages/backend/src/graphql/resolvers/ticks/queries.ts index 202fc135..f8a3f753 100644 --- a/packages/backend/src/graphql/resolvers/ticks/queries.ts +++ b/packages/backend/src/graphql/resolvers/ticks/queries.ts @@ -225,8 +225,10 @@ export const tickQueries = { const limit = validatedInput.limit ?? 20; const offset = validatedInput.offset ?? 0; - // Fetch all ticks with climb and grade data (we need all to group properly) - // For performance, we fetch a larger batch and then group/paginate + // Fetch recent ticks with climb and grade data + // We limit to 500 most recent ticks to prevent memory issues for very active users + // This provides enough data for typical pagination while keeping memory usage bounded + const MAX_FETCH_LIMIT = 500; const results = await db .select({ tick: dbSchema.boardseshTicks, @@ -252,7 +254,8 @@ export const tickQueries = { ) ) .where(eq(dbSchema.boardseshTicks.userId, userId)) - .orderBy(desc(dbSchema.boardseshTicks.climbedAt)); + .orderBy(desc(dbSchema.boardseshTicks.climbedAt)) + .limit(MAX_FETCH_LIMIT); // Group items by climbUuid and day type AscentItem = {