Skip to content
Merged
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
193 changes: 193 additions & 0 deletions packages/backend/src/graphql/resolvers/ticks/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, GroupedAscent>();

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
Expand Down
37 changes: 37 additions & 0 deletions packages/shared-schema/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!

Expand Down
Loading
Loading