diff --git a/packages/backend/src/graphql/resolvers/sessions/subscriptions.ts b/packages/backend/src/graphql/resolvers/sessions/subscriptions.ts index 0909fdd4..948a8c66 100644 --- a/packages/backend/src/graphql/resolvers/sessions/subscriptions.ts +++ b/packages/backend/src/graphql/resolvers/sessions/subscriptions.ts @@ -1,7 +1,9 @@ import type { ConnectionContext, SessionEvent } from '@boardsesh/shared-schema'; import { pubsub } from '../../../pubsub/index'; -import { requireSessionMember } from '../shared/helpers'; +import { requireSessionMember, validateInput } from '../shared/helpers'; import { createAsyncIterator } from '../shared/async-iterators'; +import { SessionIdSchema } from '../../../validation/schemas'; +import { buildSessionStatsUpdatedEvent } from './live-session-stats'; export const sessionSubscriptions = { /** @@ -27,4 +29,31 @@ export const sessionSubscriptions = { } }, }, + + /** + * Subscribe to read-only live session stats by session ID. + * This is intentionally public for share-link viewers on /session/:id pages. + */ + sessionStats: { + subscribe: async function* (_: unknown, { sessionId }: { sessionId: string }) { + validateInput(SessionIdSchema, sessionId, 'sessionId'); + + // Send a snapshot immediately so viewers get up-to-date stats on first paint. + const initial = await buildSessionStatsUpdatedEvent(sessionId); + + const asyncIterator = await createAsyncIterator((push) => { + return pubsub.subscribeSession(sessionId, push); + }); + + if (initial) { + yield { sessionStats: initial }; + } + + for await (const event of asyncIterator) { + if (event.__typename === 'SessionStatsUpdated') { + yield { sessionStats: event }; + } + } + }, + }, }; diff --git a/packages/backend/src/graphql/resolvers/social/session-feed.ts b/packages/backend/src/graphql/resolvers/social/session-feed.ts index 7fa1a242..559690f9 100644 --- a/packages/backend/src/graphql/resolvers/social/session-feed.ts +++ b/packages/backend/src/graphql/resolvers/social/session-feed.ts @@ -455,6 +455,16 @@ export const sessionFeedQueries = { const ownerUserId = isParty ? partySession?.createdByUserId || null : inferredSession?.userId || null; + const boardPath = isParty + ? partySession?.boardPath || null + : null; + const endedAtRaw = isParty + ? partySession?.endedAt ?? null + : inferredSession?.endedAt ?? null; + const endedAt = endedAtRaw + ? (endedAtRaw instanceof Date ? endedAtRaw.toISOString() : String(endedAtRaw)) + : null; + const isInProgress = isParty ? !partySession?.endedAt : false; return { sessionId, @@ -473,6 +483,9 @@ export const sessionFeedQueries = { lastTickAt, durationMinutes, goal, + boardPath, + endedAt, + isInProgress, ticks, upvotes: voteData ? Number(voteData.upvotes) : 0, downvotes: voteData ? Number(voteData.downvotes) : 0, diff --git a/packages/shared-schema/src/operations.ts b/packages/shared-schema/src/operations.ts index 19508809..ccce5157 100644 --- a/packages/shared-schema/src/operations.ts +++ b/packages/shared-schema/src/operations.ts @@ -270,6 +270,58 @@ export const SESSION_UPDATES = ` } `; +export const SESSION_STATS = ` + subscription SessionStats($sessionId: ID!) { + sessionStats(sessionId: $sessionId) { + sessionId + totalSends + totalFlashes + totalAttempts + tickCount + participants { + userId + displayName + avatarUrl + sends + flashes + attempts + } + gradeDistribution { + grade + flash + send + attempt + } + boardTypes + hardestGrade + durationMinutes + goal + ticks { + uuid + userId + climbUuid + climbName + boardType + layoutId + angle + status + attemptCount + difficulty + difficultyName + quality + isMirror + isBenchmark + comment + frames + setterUsername + climbedAt + upvotes + totalAttempts + } + } + } +`; + // Query for delta sync event replay (Phase 2) export const EVENTS_REPLAY = ` query EventsReplay($sessionId: ID!, $sinceSequence: Int!) { diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index f4a9c2cc..043fee0d 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -2682,6 +2682,12 @@ export const typeDefs = /* GraphQL */ ` lastTickAt: String! durationMinutes: Int goal: String + "Board path for party sessions (used for join links)" + boardPath: String + "When the underlying session ended (ISO 8601)" + endedAt: String + "Whether this session is currently in progress" + isInProgress: Boolean! ticks: [SessionDetailTick!]! upvotes: Int! downvotes: Int! @@ -3696,6 +3702,12 @@ export const typeDefs = /* GraphQL */ ` """ sessionUpdates(sessionId: ID!): SessionEvent! + """ + Subscribe to public live stats for a session detail page. + Intended for read-only viewers with a shared session link. + """ + sessionStats(sessionId: ID!): SessionStatsUpdated! + """ Subscribe to queue changes (items added/removed/reordered, current climb changes). """ diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 0923c0cf..a968325c 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -829,6 +829,9 @@ export type SessionDetail = { lastTickAt: string; durationMinutes?: number | null; goal?: string | null; + boardPath?: string | null; + endedAt?: string | null; + isInProgress?: boolean; ticks: SessionDetailTick[]; upvotes: number; downvotes: number; diff --git a/packages/web/app/lib/graphql/operations/activity-feed.ts b/packages/web/app/lib/graphql/operations/activity-feed.ts index 3e0ef2bf..b0e4b533 100644 --- a/packages/web/app/lib/graphql/operations/activity-feed.ts +++ b/packages/web/app/lib/graphql/operations/activity-feed.ts @@ -40,6 +40,13 @@ const SESSION_FEED_ITEM_FIELDS = ` commentCount `; +const SESSION_DETAIL_FIELDS = ` + ${SESSION_FEED_ITEM_FIELDS} + boardPath + endedAt + isInProgress +`; + export const GET_SESSION_GROUPED_FEED = gql` query GetSessionGroupedFeed($input: ActivityFeedInput) { sessionGroupedFeed(input: $input) { @@ -55,7 +62,59 @@ export const GET_SESSION_GROUPED_FEED = gql` export const GET_SESSION_DETAIL = gql` query GetSessionDetail($sessionId: ID!) { sessionDetail(sessionId: $sessionId) { - ${SESSION_FEED_ITEM_FIELDS} + ${SESSION_DETAIL_FIELDS} + ticks { + uuid + userId + climbUuid + climbName + boardType + layoutId + angle + status + attemptCount + difficulty + difficultyName + quality + isMirror + isBenchmark + comment + frames + setterUsername + climbedAt + upvotes + totalAttempts + } + } + } +`; + +export const SESSION_STATS_SUBSCRIPTION = ` + subscription SessionStats($sessionId: ID!) { + sessionStats(sessionId: $sessionId) { + sessionId + totalSends + totalFlashes + totalAttempts + tickCount + participants { + userId + displayName + avatarUrl + sends + flashes + attempts + } + gradeDistribution { + grade + flash + send + attempt + } + boardTypes + hardestGrade + durationMinutes + goal ticks { uuid userId @@ -89,7 +148,7 @@ export const GET_SESSION_DETAIL = gql` export const UPDATE_INFERRED_SESSION = gql` mutation UpdateInferredSession($input: UpdateInferredSessionInput!) { updateInferredSession(input: $input) { - ${SESSION_FEED_ITEM_FIELDS} + ${SESSION_DETAIL_FIELDS} ticks { uuid userId @@ -119,7 +178,7 @@ export const UPDATE_INFERRED_SESSION = gql` export const ADD_USER_TO_SESSION = gql` mutation AddUserToSession($input: AddUserToSessionInput!) { addUserToSession(input: $input) { - ${SESSION_FEED_ITEM_FIELDS} + ${SESSION_DETAIL_FIELDS} ticks { uuid userId @@ -149,7 +208,7 @@ export const ADD_USER_TO_SESSION = gql` export const REMOVE_USER_FROM_SESSION = gql` mutation RemoveUserFromSession($input: RemoveUserFromSessionInput!) { removeUserFromSession(input: $input) { - ${SESSION_FEED_ITEM_FIELDS} + ${SESSION_DETAIL_FIELDS} ticks { uuid userId @@ -195,3 +254,11 @@ export interface GetSessionDetailQueryVariables { export interface GetSessionDetailQueryResponse { sessionDetail: import('@boardsesh/shared-schema').SessionDetail | null; } + +export interface SessionStatsSubscriptionVariables { + sessionId: string; +} + +export interface SessionStatsSubscriptionResponse { + sessionStats: import('@boardsesh/shared-schema').SessionLiveStats; +} diff --git a/packages/web/app/session/[sessionId]/page.tsx b/packages/web/app/session/[sessionId]/page.tsx index dd8ece29..89f5749d 100644 --- a/packages/web/app/session/[sessionId]/page.tsx +++ b/packages/web/app/session/[sessionId]/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from 'next'; import { GraphQLClient } from 'graphql-request'; import { getGraphQLHttpUrl } from '@/app/lib/graphql/client'; import { GET_SESSION_DETAIL, type GetSessionDetailQueryResponse } from '@/app/lib/graphql/operations/activity-feed'; -import SessionDetailContent from './session-detail-content'; +import SessionDetailLiveShell from './session-detail-live-shell'; type Props = { params: Promise<{ sessionId: string }>; @@ -48,5 +48,5 @@ export default async function SessionDetailPage({ params }: Props) { const sessionId = decodeURIComponent(rawSessionId); const session = await fetchSessionDetail(sessionId); - return ; + return ; } diff --git a/packages/web/app/session/[sessionId]/session-detail-content.tsx b/packages/web/app/session/[sessionId]/session-detail-content.tsx index f72a3242..d782878d 100644 --- a/packages/web/app/session/[sessionId]/session-detail-content.tsx +++ b/packages/web/app/session/[sessionId]/session-detail-content.tsx @@ -4,6 +4,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import Collapse from '@mui/material/Collapse'; import Divider from '@mui/material/Divider'; @@ -31,6 +32,7 @@ import { useMyBoards } from '@/app/hooks/use-my-boards'; import { useBoardDetailsMap } from '@/app/hooks/use-board-details-map'; import { getDefaultAngleForBoard } from '@/app/lib/board-config-for-playlist'; import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; +import BoardRenderer from '@/app/components/board-renderer/board-renderer'; import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; import { @@ -44,10 +46,18 @@ import type { Climb, BoardName, BoardDetails } from '@/app/lib/types'; import UserSearchDialog from './user-search-dialog'; import SessionOverviewPanel from '@/app/components/session-details/session-overview-panel'; +interface SessionBoardPreview { + boardName: string; + boardDetails: BoardDetails; + angle: number; +} + interface SessionDetailContentProps { session: SessionDetail | null; embedded?: boolean; fallbackBoardDetails?: BoardDetails | null; + joinSessionUrl?: string | null; + sessionBoardPreview?: SessionBoardPreview | null; } const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; @@ -254,6 +264,8 @@ export default function SessionDetailContent({ session: initialSession, embedded = false, fallbackBoardDetails = null, + joinSessionUrl = null, + sessionBoardPreview = null, }: SessionDetailContentProps) { const { data: authSession } = useSession(); const { token: authToken } = useWsAuthToken(); @@ -570,6 +582,48 @@ export default function SessionDetailContent({ /> )} + {sessionBoardPreview && ( + + + Session Board + + + + {sessionBoardPreview.boardName} + + + + + {session.isInProgress && joinSessionUrl && ( + + )} + + )} + {/* Session-level social */} diff --git a/packages/web/app/session/[sessionId]/session-detail-live-shell.tsx b/packages/web/app/session/[sessionId]/session-detail-live-shell.tsx new file mode 100644 index 00000000..7480e6f1 --- /dev/null +++ b/packages/web/app/session/[sessionId]/session-detail-live-shell.tsx @@ -0,0 +1,189 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import type { SessionDetail, SessionLiveStats } from '@boardsesh/shared-schema'; +import type { BoardDetails } from '@/app/lib/types'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { createGraphQLClient, subscribe } from '@/app/components/graphql-queue/graphql-client'; +import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; +import { getUserBoardDetails } from '@/app/lib/board-config-for-playlist'; +import { constructBoardSlugListUrl } from '@/app/lib/url-utils'; +import { + SESSION_STATS_SUBSCRIPTION, + type SessionStatsSubscriptionResponse, +} from '@/app/lib/graphql/operations/activity-feed'; +import { + GET_BOARD_BY_SLUG, + type GetBoardBySlugQueryResponse, +} from '@/app/lib/graphql/operations/boards'; +import SessionDetailContent from './session-detail-content'; + +interface SessionDetailLiveShellProps { + sessionId: string; + initialSession: SessionDetail | null; +} + +interface SessionBoardPreview { + boardName: string; + boardDetails: BoardDetails; + angle: number; +} + +function mergeStatsIntoSession( + previous: SessionDetail, + liveStats: SessionLiveStats, +): SessionDetail { + const mergedTicks = liveStats.ticks ?? previous.ticks; + const firstTickAt = mergedTicks.length > 0 + ? mergedTicks[mergedTicks.length - 1]?.climbedAt ?? previous.firstTickAt + : previous.firstTickAt; + const lastTickAt = mergedTicks.length > 0 + ? mergedTicks[0]?.climbedAt ?? previous.lastTickAt + : previous.lastTickAt; + + return { + ...previous, + participants: liveStats.participants, + totalSends: liveStats.totalSends, + totalFlashes: liveStats.totalFlashes, + totalAttempts: liveStats.totalAttempts, + tickCount: liveStats.tickCount, + gradeDistribution: liveStats.gradeDistribution, + boardTypes: liveStats.boardTypes, + hardestGrade: liveStats.hardestGrade, + durationMinutes: liveStats.durationMinutes, + goal: liveStats.goal, + ticks: mergedTicks, + firstTickAt, + lastTickAt, + }; +} + +function getBoardSlug(boardPath: string | null | undefined): string | null { + if (!boardPath) return null; + const match = boardPath.match(/^\/b\/([^/]+)(?:\/|$)/); + return match?.[1] ?? null; +} + +export default function SessionDetailLiveShell({ + sessionId, + initialSession, +}: SessionDetailLiveShellProps) { + const { token: authToken } = useWsAuthToken(); + const [session, setSession] = useState(initialSession); + const [boardPreview, setBoardPreview] = useState(null); + const [joinSessionUrl, setJoinSessionUrl] = useState(null); + + useEffect(() => { + setSession(initialSession); + }, [initialSession]); + + useEffect(() => { + if (!session?.isInProgress) return; + + const wsUrl = process.env.NEXT_PUBLIC_WS_URL; + if (!wsUrl) return; + + const wsClient = createGraphQLClient({ + url: wsUrl, + authToken, + }); + + const unsubscribe = subscribe( + wsClient, + { + query: SESSION_STATS_SUBSCRIPTION, + variables: { sessionId }, + }, + { + next: (data) => { + if (!data?.sessionStats || data.sessionStats.sessionId !== sessionId) return; + setSession((previous) => ( + previous + ? mergeStatsIntoSession(previous, data.sessionStats) + : previous + )); + }, + error: (err) => { + console.error('[SessionDetailLiveShell] sessionStats subscription error:', err); + }, + complete: () => {}, + }, + ); + + return () => { + unsubscribe(); + wsClient.dispose(); + }; + }, [authToken, session?.isInProgress, sessionId]); + + const boardSlug = useMemo( + () => getBoardSlug(session?.boardPath), + [session?.boardPath], + ); + + useEffect(() => { + let cancelled = false; + + async function loadBoardPreview() { + if (!session?.isInProgress || !boardSlug) { + if (!cancelled) { + setBoardPreview(null); + setJoinSessionUrl(null); + } + return; + } + + try { + const client = createGraphQLHttpClient(authToken); + const response = await client.request( + GET_BOARD_BY_SLUG, + { slug: boardSlug }, + ); + + if (cancelled) return; + const board = response.boardBySlug; + if (!board) { + setBoardPreview(null); + setJoinSessionUrl(null); + return; + } + + const boardDetails = getUserBoardDetails(board); + if (!boardDetails) { + setBoardPreview(null); + setJoinSessionUrl(null); + return; + } + + const baseUrl = constructBoardSlugListUrl(board.slug, board.angle); + setJoinSessionUrl(`${baseUrl}?session=${encodeURIComponent(sessionId)}`); + setBoardPreview({ + boardName: board.name, + boardDetails, + angle: board.angle, + }); + } catch (err) { + console.error('[SessionDetailLiveShell] Failed to resolve board preview:', err); + if (!cancelled) { + setBoardPreview(null); + setJoinSessionUrl(null); + } + } + } + + loadBoardPreview(); + + return () => { + cancelled = true; + }; + }, [authToken, boardSlug, session?.isInProgress, sessionId]); + + return ( + + ); +} 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 e750f6c8..fa070fcd 100644 --- a/packages/web/app/session/__tests__/session-detail-content.test.tsx +++ b/packages/web/app/session/__tests__/session-detail-content.test.tsx @@ -40,10 +40,10 @@ vi.mock('@/app/theme/theme-config', () => ({ themeTokens: { transitions: { normal: '200ms ease' }, shadows: { md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }, - borderRadius: { full: 9999, sm: 4 }, + borderRadius: { full: 9999, sm: 4, lg: 12 }, colors: { amber: '#FBBF24', success: '#6B9080', successBg: '#EFF5F2' }, typography: { fontSize: { xs: 12 } }, - neutral: { 100: '#F3F4F6', 200: '#E5E7EB', 300: '#D1D5DB' }, + neutral: { 50: '#F9FAFB', 100: '#F3F4F6', 200: '#E5E7EB', 300: '#D1D5DB' }, }, })); @@ -126,6 +126,10 @@ vi.mock('@/app/components/board-renderer/util', () => ({ convertLitUpHoldsStringToMap: () => [{}], })); +vi.mock('@/app/components/board-renderer/board-renderer', () => ({ + default: () =>
, +})); + import SessionDetailContent from '../[sessionId]/session-detail-content'; function makeSession(overrides: Partial = {}): SessionDetail { @@ -502,4 +506,52 @@ describe('SessionDetailContent', () => { render(); expect(screen.getByText('Great beta!')).toBeTruthy(); }); + + it('renders session board preview when provided', () => { + render( + , + ); + + expect(screen.getByText('Session Board')).toBeTruthy(); + expect(screen.getByText('Moonrise Home Wall')).toBeTruthy(); + expect(screen.getByTestId('board-renderer')).toBeTruthy(); + }); + + it('renders join session link for in-progress sessions', () => { + render( + , + ); + + const joinLink = screen.getByRole('link', { name: 'Join Session' }); + expect(joinLink.getAttribute('href')).toBe('/b/my-board/40/list?session=session-1'); + }); });