Skip to content
Open
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
31 changes: 30 additions & 1 deletion packages/backend/src/graphql/resolvers/sessions/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -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 = {
/**
Expand All @@ -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<SessionEvent>((push) => {
return pubsub.subscribeSession(sessionId, push);
Comment on lines +42 to +45

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Subscribe before taking session stats snapshot

Fetching initial before attaching the pubsub listener creates a race where ticks logged in between are never delivered: buildSessionStatsUpdatedEvent captures old totals, then pubsub.subscribeSession starts after that point, so the in-between SessionStatsUpdated event is dropped and viewers can remain permanently stale if no later ticks arrive. This resolver should subscribe first (or use the eager iterator path) and then emit a snapshot to avoid missing updates.

Useful? React with 👍 / 👎.

});

if (initial) {
yield { sessionStats: initial };
}

for await (const event of asyncIterator) {
if (event.__typename === 'SessionStatsUpdated') {
yield { sessionStats: event };
Comment on lines +52 to +54

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate session end state in sessionStats stream

The subscription drops every non-SessionStatsUpdated event, but session termination is published as SessionEnded (from sessions/mutations.ts), so clients using this stream never learn that an in-progress session ended. On the session detail page this leaves isInProgress stale and can keep showing "Join Session" until a hard refresh. The stream needs a way to signal end-of-session state transitions.

Useful? React with 👍 / 👎.

}
}
},
},
};
13 changes: 13 additions & 0 deletions packages/backend/src/graphql/resolvers/social/session-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions packages/shared-schema/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down
12 changes: 12 additions & 0 deletions packages/shared-schema/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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).
"""
Expand Down
3 changes: 3 additions & 0 deletions packages/shared-schema/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
75 changes: 71 additions & 4 deletions packages/web/app/lib/graphql/operations/activity-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions packages/web/app/session/[sessionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -48,5 +48,5 @@ export default async function SessionDetailPage({ params }: Props) {
const sessionId = decodeURIComponent(rawSessionId);
const session = await fetchSessionDetail(sessionId);

return <SessionDetailContent session={session} />;
return <SessionDetailLiveShell sessionId={sessionId} initialSession={session} />;
}
54 changes: 54 additions & 0 deletions packages/web/app/session/[sessionId]/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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'];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -570,6 +582,48 @@ export default function SessionDetailContent({
/>
)}

{sessionBoardPreview && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
p: 1.25,
border: `1px solid ${themeTokens.neutral[200]}`,
borderRadius: themeTokens.borderRadius.lg,
bgcolor: themeTokens.neutral[50],
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Session Board
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{sessionBoardPreview.boardName}
</Typography>
<Chip label={`${sessionBoardPreview.angle}°`} size="small" variant="outlined" />
</Box>
<BoardRenderer
litUpHoldsMap={{}}
mirrored={false}
boardDetails={sessionBoardPreview.boardDetails}
thumbnail
maxHeight="140px"
/>
{session.isInProgress && joinSessionUrl && (
<Button
component={Link}
href={joinSessionUrl}
variant="contained"
size="small"
fullWidth
>
Join Session
</Button>
)}
</Box>
)}

{/* Session-level social */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
Expand Down
Loading
Loading