diff --git a/packages/backend/src/graphql/resolvers/social/session-feed.ts b/packages/backend/src/graphql/resolvers/social/session-feed.ts index b0ae8e37..de7b0503 100644 --- a/packages/backend/src/graphql/resolvers/social/session-feed.ts +++ b/packages/backend/src/graphql/resolvers/social/session-feed.ts @@ -252,6 +252,24 @@ export const sessionFeedQueries = { if (tickRows.length === 0) return null; + // Batch-fetch tick vote counts + const tickUuids = tickRows.map((r) => r.tick.uuid); + const tickVoteCounts = tickUuids.length > 0 + ? await db + .select({ + entityId: dbSchema.voteCounts.entityId, + upvotes: sql`COALESCE(${dbSchema.voteCounts.upvotes}, 0)`, + }) + .from(dbSchema.voteCounts) + .where( + and( + eq(dbSchema.voteCounts.entityType, 'tick'), + inArray(dbSchema.voteCounts.entityId, tickUuids), + ), + ) + : []; + const tickVoteMap = new Map(tickVoteCounts.map((v) => [v.entityId, Number(v.upvotes)])); + // Build ticks const ticks: SessionDetailTick[] = tickRows.map((row) => ({ uuid: row.tick.uuid, @@ -272,6 +290,7 @@ export const sessionFeedQueries = { frames: row.frames || null, setterUsername: row.setterUsername || null, climbedAt: row.tick.climbedAt, + upvotes: tickVoteMap.get(row.tick.uuid) ?? 0, })); // Compute aggregates diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 893dc287..81c60163 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -2643,6 +2643,7 @@ export const typeDefs = /* GraphQL */ ` frames: String setterUsername: String climbedAt: String! + upvotes: Int! } """ diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 5ab89f04..10a4da32 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -808,6 +808,7 @@ export type SessionDetailTick = { frames?: string | null; setterUsername?: string | null; climbedAt: string; + upvotes: number; }; export type SessionDetail = { diff --git a/packages/web/app/components/social/feed-comment-button.tsx b/packages/web/app/components/social/feed-comment-button.tsx index 9d2463b4..b8ee221a 100644 --- a/packages/web/app/components/social/feed-comment-button.tsx +++ b/packages/web/app/components/social/feed-comment-button.tsx @@ -13,14 +13,16 @@ interface FeedCommentButtonProps { entityType: SocialEntityType; entityId: string; commentCount?: number; + defaultExpanded?: boolean; } export default function FeedCommentButton({ entityType, entityId, commentCount = 0, + defaultExpanded = false, }: FeedCommentButtonProps) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultExpanded); const handleToggle = useCallback((e: React.MouseEvent) => { e.stopPropagation(); diff --git a/packages/web/app/lib/graphql/operations/activity-feed.ts b/packages/web/app/lib/graphql/operations/activity-feed.ts index 3c597ea9..847450bd 100644 --- a/packages/web/app/lib/graphql/operations/activity-feed.ts +++ b/packages/web/app/lib/graphql/operations/activity-feed.ts @@ -75,6 +75,7 @@ export const GET_SESSION_DETAIL = gql` frames setterUsername climbedAt + upvotes } } } @@ -107,6 +108,7 @@ export const UPDATE_INFERRED_SESSION = gql` frames setterUsername climbedAt + upvotes } } } @@ -135,6 +137,7 @@ export const ADD_USER_TO_SESSION = gql` frames setterUsername climbedAt + upvotes } } } @@ -163,6 +166,7 @@ export const REMOVE_USER_FROM_SESSION = gql` frames setterUsername climbedAt + upvotes } } } diff --git a/packages/web/app/session/[sessionId]/session-detail-content.tsx b/packages/web/app/session/[sessionId]/session-detail-content.tsx index 56fcb543..efc6dd58 100644 --- a/packages/web/app/session/[sessionId]/session-detail-content.tsx +++ b/packages/web/app/session/[sessionId]/session-detail-content.tsx @@ -30,7 +30,7 @@ import { useSession } from 'next-auth/react'; import type { SessionDetail, SessionDetailTick, SessionFeedParticipant } from '@boardsesh/shared-schema'; import GradeDistributionBar from '@/app/components/charts/grade-distribution-bar'; import VoteButton from '@/app/components/social/vote-button'; -import CommentSection from '@/app/components/social/comment-section'; +import FeedCommentButton from '@/app/components/social/feed-comment-button'; import ClimbsList from '@/app/components/board-page/climbs-list'; import { FavoritesProvider } from '@/app/components/climb-actions/favorites-batch-context'; import { PlaylistsProvider } from '@/app/components/climb-actions/playlists-batch-context'; @@ -201,6 +201,7 @@ export default function SessionDetailContent({ session: initialSession }: Sessio ticks, upvotes, downvotes, + commentCount, } = session; const currentUserId = authSession?.user?.id; @@ -367,9 +368,8 @@ export default function SessionDetailContent({ session: initialSession }: Sessio @@ -676,10 +676,14 @@ export default function SessionDetailContent({ session: initialSession }: Sessio initialDownvotes={downvotes} likeOnly /> + - - {/* Climbs list */} diff --git a/packages/web/app/session/__tests__/session-detail-content.test.tsx b/packages/web/app/session/__tests__/session-detail-content.test.tsx index 854a985e..cc5d1668 100644 --- a/packages/web/app/session/__tests__/session-detail-content.test.tsx +++ b/packages/web/app/session/__tests__/session-detail-content.test.tsx @@ -56,8 +56,8 @@ vi.mock('@/app/components/charts/grade-distribution-bar', () => ({ })); vi.mock('@/app/components/social/vote-button', () => ({ - default: ({ entityType, entityId }: { entityType: string; entityId: string }) => ( -
+ default: ({ entityType, entityId, initialUpvotes }: { entityType: string; entityId: string; initialUpvotes?: number }) => ( +
), })); @@ -66,9 +66,9 @@ vi.mock('@/app/components/social/vote-summary-context', () => ({ useVoteSummaryContext: () => null, })); -vi.mock('@/app/components/social/comment-section', () => ({ - default: ({ entityType, entityId }: { entityType: string; entityId: string }) => ( -
+vi.mock('@/app/components/social/feed-comment-button', () => ({ + default: ({ entityType, entityId, commentCount }: { entityType: string; entityId: string; commentCount?: number }) => ( +
), })); @@ -169,6 +169,7 @@ function makeSession(overrides: Partial = {}): SessionDetail { frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T10:30:00.000Z', + upvotes: 3, }, ], upvotes: 5, @@ -210,11 +211,12 @@ describe('SessionDetailContent', () => { expect(sessionVote!.getAttribute('data-entity-id')).toBe('session-1'); }); - it('renders CommentSection with session entity type', () => { + it('renders FeedCommentButton with session entity type and comment count', () => { render(); - const commentSection = screen.getByTestId('comment-section'); - expect(commentSection.getAttribute('data-entity-type')).toBe('session'); - expect(commentSection.getAttribute('data-entity-id')).toBe('session-1'); + const commentButton = screen.getByTestId('feed-comment-button'); + expect(commentButton.getAttribute('data-entity-type')).toBe('session'); + expect(commentButton.getAttribute('data-entity-id')).toBe('session-1'); + expect(commentButton.getAttribute('data-comment-count')).toBe('2'); }); it('shows session name when available', () => { @@ -267,7 +269,7 @@ describe('SessionDetailContent', () => { expect(screen.getByText('Hardest: V8')).toBeTruthy(); }); - it('renders per-tick VoteButton with tick entity type for single-user sessions', () => { + it('renders per-tick VoteButton with tick entity type and SSR upvotes', () => { render(); const voteButtons = screen.getAllByTestId('vote-button'); const tickVote = voteButtons.find( @@ -275,6 +277,7 @@ describe('SessionDetailContent', () => { ); expect(tickVote).toBeTruthy(); expect(tickVote!.getAttribute('data-entity-id')).toBe('tick-1'); + expect(tickVote!.getAttribute('data-initial-upvotes')).toBe('3'); }); it('renders tick status details for single-user sessions', () => { @@ -290,13 +293,13 @@ describe('SessionDetailContent', () => { uuid: 'tick-1', userId: 'user-1', climbUuid: 'climb-1', climbName: 'Duplicated Climb', boardType: 'kilter', layoutId: 1, angle: 40, status: 'send', attemptCount: 1, difficulty: 20, difficultyName: 'V5', quality: 3, isMirror: false, isBenchmark: false, - comment: null, frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T10:30:00.000Z', + comment: null, frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T10:30:00.000Z', upvotes: 0, }, { uuid: 'tick-2', userId: 'user-1', climbUuid: 'climb-1', climbName: 'Duplicated Climb', boardType: 'kilter', layoutId: 1, angle: 40, status: 'flash', attemptCount: 1, difficulty: 20, difficultyName: 'V5', quality: 3, isMirror: false, isBenchmark: false, - comment: null, frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T11:00:00.000Z', + comment: null, frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T11:00:00.000Z', upvotes: 0, }, ], }); @@ -316,13 +319,13 @@ describe('SessionDetailContent', () => { uuid: 'tick-1', userId: 'user-1', climbUuid: 'climb-1', climbName: 'Shared Climb', boardType: 'kilter', layoutId: 1, angle: 40, status: 'send', attemptCount: 2, difficulty: 20, difficultyName: 'V5', quality: 3, isMirror: false, isBenchmark: false, - comment: null, frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T10:30:00.000Z', + comment: null, frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T10:30:00.000Z', upvotes: 0, }, { uuid: 'tick-2', userId: 'user-2', climbUuid: 'climb-1', climbName: 'Shared Climb', boardType: 'kilter', layoutId: 1, angle: 40, status: 'flash', attemptCount: 1, difficulty: 20, difficultyName: 'V5', quality: 3, isMirror: false, isBenchmark: false, - comment: null, frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T10:35:00.000Z', + comment: null, frames: 'abc', setterUsername: 'setter1', climbedAt: '2024-01-15T10:35:00.000Z', upvotes: 0, }, ], });