diff --git a/packages/backend/src/graphql/resolvers/ticks/queries.ts b/packages/backend/src/graphql/resolvers/ticks/queries.ts index 8915b1a1..f8a3f753 100644 --- a/packages/backend/src/graphql/resolvers/ticks/queries.ts +++ b/packages/backend/src/graphql/resolvers/ticks/queries.ts @@ -208,6 +208,199 @@ 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 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, + 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)) + .limit(MAX_FETCH_LIMIT); + + // 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 7157c24d..c3ec8ea7 100644 --- a/packages/web/app/components/activity-feed/ascents-feed.tsx +++ b/packages/web/app/components/activity-feed/ascents-feed.tsx @@ -12,17 +12,17 @@ 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'; dayjs.extend(relativeTime); -const { Text, Title } = Typography; +const { Text } = Typography; interface AscentsFeedProps { userId: string; @@ -44,59 +44,61 @@ 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 ; - } -}; +// Generate status summary for grouped attempts +const getGroupStatusSummary = (group: GroupedAscentFeedItem): { text: string; icon: React.ReactNode; color: string } => { + const parts: string[] = []; -const getStatusColor = (status: 'flash' | 'send' | 'attempt') => { - switch (status) { - case 'flash': - return 'gold'; - case 'send': - return 'green'; - case 'attempt': - return 'default'; + // 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: 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 + ); + 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 +108,14 @@ const FeedItem: React.FC<{ item: AscentFeedItem }> = ({ item }) => { - {statusText} + {statusSummary.text} - {item.climbName} + {group.climbName} @@ -123,39 +125,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} )} @@ -164,7 +159,7 @@ const FeedItem: React.FC<{ item: AscentFeedItem }> = ({ item }) => { }; 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); @@ -182,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); @@ -221,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); } }; @@ -242,7 +237,7 @@ export const AscentsFeed: React.FC = ({ userId, pageSize = 10 ); } - if (items.length === 0) { + if (groups.length === 0) { return ( = ({ userId, pageSize = 10 return (
- {items.map((item) => ( - + {groups.map((group) => ( + ))} @@ -267,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 // ============================================