From 2d78b9ff076e6574e40de932ad0fe9b2b9d2a3a0 Mon Sep 17 00:00:00 2001 From: Jan Willem Mannaerts Date: Wed, 4 Mar 2026 09:55:49 +0100 Subject: [PATCH] Track participants at room level to fix premature auto-reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Participants were tracked per-session, so each new issue started with 0 participants. The first user to join+vote saw 1/1 = all voted, triggering premature reveal. Now members are tracked on the room object and persist across issues. revealIfComplete compares votes against room member count. Also fixes: disconnect handler was dead code (Socket.IO v4 empties socket.rooms before firing disconnect) — replaced with disconnecting. Added manual "Reveal Votes" button and poker:reveal socket handler. Co-Authored-By: Claude Opus 4.6 --- backend/src/index.js | 193 ++++++++++++++------------ backend/src/services/pokerService.js | 78 +++++------ backend/src/services/roomService.js | 49 ++++++- frontend/public/logo-256.png | Bin 0 -> 5532 bytes frontend/src/components/PokerRoom.jsx | 88 ++++++------ frontend/src/components/Room.jsx | 29 ++-- 6 files changed, 247 insertions(+), 190 deletions(-) create mode 100644 frontend/public/logo-256.png diff --git a/backend/src/index.js b/backend/src/index.js index c17429d..6f72a8b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -14,15 +14,19 @@ import pokerRoutes from './routes/poker.js'; import jiraRoutes from './routes/jira.js'; import roomRoutes from './routes/rooms.js'; import { - canAccessSession, getSessionSnapshot, - isSessionParticipant, - joinSession, - leaveSession, revealIfComplete, + forceReveal, saveScopedEstimate, submitVote } from './services/pokerService.js'; +import { + joinRoom, + leaveRoom, + getRoomMembers, + getRoomMemberCount, + isRoomMember +} from './services/roomService.js'; import { updateIssueEstimate } from './services/jiraService.js'; import { getSocketUser } from './middleware/auth.js'; import { safeError } from './lib/errors.js'; @@ -134,28 +138,26 @@ const io = new Server(httpServer, { } }); -async function emitSessionState(sessionId, tenantCloudId) { +async function emitRoomMembers(roomId, cloudId) { + const members = await getRoomMembers(roomId, cloudId); + io.to(`room:${roomId}`).emit('room:members', { roomId, members }); +} + +async function emitSessionState(roomId, sessionId, tenantCloudId) { const snapshot = await getSessionSnapshot(sessionId, tenantCloudId); if (!snapshot) return; - io.to(`poker:${sessionId}`).emit('poker:participants', { - sessionId, - participants: snapshot.participants, - votedUserKeys: snapshot.votedUserKeys, - voteCount: snapshot.voteCount, - participantCount: snapshot.participantCount - }); + const memberCount = await getRoomMemberCount(roomId, tenantCloudId); - io.to(`poker:${sessionId}`).emit('poker:vote-update', { + io.to(`room:${roomId}`).emit('poker:vote-update', { sessionId, voteCount: snapshot.voteCount, - participantCount: snapshot.participantCount, votedUserKeys: snapshot.votedUserKeys, - allVoted: snapshot.voteCount > 0 && snapshot.voteCount === snapshot.participantCount + memberCount }); if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') { - io.to(`poker:${sessionId}`).emit('poker:revealed', { + io.to(`room:${roomId}`).emit('poker:revealed', { sessionId, votes: snapshot.votesByUser, average: snapshot.session.averageEstimate, @@ -194,52 +196,54 @@ io.on('connection', (socket) => { websocketConnections.inc(); trackUniqueUser(user.jiraAccountId); trackUniqueTenant(user.jiraCloudId); - socket.on('disconnect', async () => { + + // Use 'disconnecting' — socket.rooms is still populated (unlike 'disconnect') + socket.on('disconnecting', async () => { websocketConnections.dec(); - for (const room of socket.rooms) { - if (!room.startsWith('poker:')) continue; - const sessionId = room.slice(6); - try { - await leaveSession({ - sessionId, - tenantCloudId: socket.user.jiraCloudId, - userKey: socket.user.jiraAccountId - }); - await emitSessionState(sessionId, socket.user.jiraCloudId); - } catch { - // best-effort cleanup - } + const roomId = socket.roomId; + if (!roomId) return; + try { + await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId); + await emitRoomMembers(roomId, socket.user.jiraCloudId); + } catch { + // best-effort cleanup } }); + const throttled = socketThrottle(socket); - throttled('poker:join', async ({ sessionId }) => { + throttled('room:join', async ({ roomId }) => { try { - if (!sessionId) { - socket.emit('poker:error', { error: 'sessionId is required.' }); + if (!roomId) { + socket.emit('poker:error', { error: 'roomId is required.' }); return; } - if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) { - socket.emit('poker:error', { error: 'Session not found.' }); - return; - } - - socket.join(`poker:${sessionId}`); - const snapshot = await joinSession({ - sessionId, - tenantCloudId: socket.user.jiraCloudId, + await joinRoom(roomId, socket.user.jiraCloudId, { userKey: socket.user.jiraAccountId, userName: socket.user.displayName, avatarUrl: socket.user.avatarUrl || null }); - if (!snapshot) { - socket.emit('poker:error', { error: 'Session not found.' }); - return; - } - await emitSessionState(sessionId, socket.user.jiraCloudId); + + socket.join(`room:${roomId}`); + socket.roomId = roomId; + + await emitRoomMembers(roomId, socket.user.jiraCloudId); } catch (error) { - console.error('[socket] poker:join failed:', error); + console.error('[socket] room:join failed:', error); + socket.emit('poker:error', { error: safeError(error) }); + } + }); + + throttled('room:leave', async ({ roomId }) => { + try { + if (!roomId) return; + await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId); + socket.leave(`room:${roomId}`); + socket.roomId = null; + await emitRoomMembers(roomId, socket.user.jiraCloudId); + } catch (error) { + console.error('[socket] room:leave failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); @@ -251,12 +255,15 @@ io.on('connection', (socket) => { return; } - if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) { + const snapshot = await getSessionSnapshot(sessionId, socket.user.jiraCloudId); + if (!snapshot) { socket.emit('poker:error', { error: 'Session not found.' }); return; } - if (!await isSessionParticipant(sessionId, socket.user.jiraCloudId, socket.user.jiraAccountId)) { - socket.emit('poker:error', { error: 'Join the session before voting.' }); + + const roomId = snapshot.session.roomId; + if (!await isRoomMember(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId)) { + socket.emit('poker:error', { error: 'Join the room before voting.' }); return; } @@ -271,12 +278,14 @@ io.on('connection', (socket) => { return; } votesTotal.inc(); - const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId); - await emitSessionState(sessionId, socket.user.jiraCloudId); + const memberCount = await getRoomMemberCount(roomId, socket.user.jiraCloudId); + const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId, memberCount); + + await emitSessionState(roomId, sessionId, socket.user.jiraCloudId); if (reveal?.allVoted) { - io.to(`poker:${sessionId}`).emit('poker:revealed', { + io.to(`room:${roomId}`).emit('poker:revealed', { sessionId, votes: reveal.votesByUser, average: reveal.average, @@ -289,6 +298,34 @@ io.on('connection', (socket) => { } }); + throttled('poker:reveal', async ({ sessionId }) => { + try { + if (!sessionId) { + socket.emit('poker:error', { error: 'sessionId is required.' }); + return; + } + + const snapshot = await forceReveal(sessionId, socket.user.jiraCloudId); + if (!snapshot) { + socket.emit('poker:error', { error: 'Unable to reveal votes.' }); + return; + } + + const roomId = snapshot.session.roomId; + io.to(`room:${roomId}`).emit('poker:revealed', { + sessionId, + votes: snapshot.votesByUser, + average: snapshot.session.averageEstimate, + suggestedEstimate: snapshot.session.suggestedEstimate + }); + + await emitSessionState(roomId, sessionId, socket.user.jiraCloudId); + } catch (error) { + console.error('[socket] poker:reveal failed:', error); + socket.emit('poker:error', { error: safeError(error) }); + } + }); + throttled('poker:save', async ({ sessionId, estimate }) => { try { if (!sessionId) { @@ -296,12 +333,15 @@ io.on('connection', (socket) => { return; } - if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) { + const pre = await getSessionSnapshot(sessionId, socket.user.jiraCloudId); + if (!pre) { socket.emit('poker:error', { error: 'Session not found.' }); return; } - if (!await isSessionParticipant(sessionId, socket.user.jiraCloudId, socket.user.jiraAccountId)) { - socket.emit('poker:error', { error: 'Join the session before saving.' }); + const roomId = pre.session.roomId; + + if (!await isRoomMember(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId)) { + socket.emit('poker:error', { error: 'Join the room before saving.' }); return; } @@ -330,57 +370,32 @@ io.on('connection', (socket) => { // Jira update is best-effort so poker flow continues even when Jira is unavailable. } - io.to(`poker:${sessionId}`).emit('poker:saved', { + io.to(`room:${roomId}`).emit('poker:saved', { sessionId, estimate: numericEstimate, issueKey: saved.session.issueKey }); - io.to(`poker:${sessionId}`).emit('poker:ended', { sessionId }); + io.to(`room:${roomId}`).emit('poker:ended', { sessionId }); } catch (error) { console.error('[socket] poker:save failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); - throttled('poker:kick', async ({ sessionId, userKey }) => { + throttled('poker:kick', async ({ roomId, userKey }) => { try { - if (!sessionId || !userKey) { - socket.emit('poker:error', { error: 'sessionId and userKey are required.' }); + if (!roomId || !userKey) { + socket.emit('poker:error', { error: 'roomId and userKey are required.' }); return; } - if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) { - socket.emit('poker:error', { error: 'Session not found.' }); - return; - } - await leaveSession({ - sessionId, - tenantCloudId: socket.user.jiraCloudId, - userKey - }); - await emitSessionState(sessionId, socket.user.jiraCloudId); + await leaveRoom(roomId, socket.user.jiraCloudId, userKey); + await emitRoomMembers(roomId, socket.user.jiraCloudId); } catch (error) { console.error('[socket] poker:kick failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); - - throttled('poker:leave', async ({ sessionId }) => { - try { - if (!sessionId) return; - if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) return; - await leaveSession({ - sessionId, - tenantCloudId: socket.user.jiraCloudId, - userKey: socket.user.jiraAccountId - }); - socket.leave(`poker:${sessionId}`); - await emitSessionState(sessionId, socket.user.jiraCloudId); - } catch (error) { - console.error('[socket] poker:leave failed:', error); - socket.emit('poker:error', { error: safeError(error) }); - } - }); }); async function start() { diff --git a/backend/src/services/pokerService.js b/backend/src/services/pokerService.js index b947a2b..7c511b5 100644 --- a/backend/src/services/pokerService.js +++ b/backend/src/services/pokerService.js @@ -27,14 +27,12 @@ function nearestFibonacci(value) { function serializeSession(session) { return { ...session, - participants: Object.fromEntries(session.participants), votes: Object.fromEntries(session.votes) }; } function deserializeSession(data) { if (!data) return null; - data.participants = new Map(Object.entries(data.participants)); data.votes = new Map(Object.entries(data.votes)); return data; } @@ -64,21 +62,17 @@ async function withSessionCas(cloudId, sessionId, transformFn) { } function getSnapshot(session) { - // Accept both Map-based and plain-object sessions - const participantsMap = session.participants instanceof Map - ? session.participants - : new Map(Object.entries(session.participants)); const votesMap = session.votes instanceof Map ? session.votes : new Map(Object.entries(session.votes)); - const participants = [...participantsMap.values()]; const votes = Object.fromEntries(votesMap); const votedUserKeys = [...votesMap.keys()]; return { session: { id: session.id, + roomId: session.roomId, issueKey: session.issueKey, issueId: session.issueId, issueTitle: session.issueTitle, @@ -88,10 +82,8 @@ function getSnapshot(session) { suggestedEstimate: session.suggestedEstimate, savedEstimate: session.savedEstimate }, - participants, votesByUser: votes, voteCount: votesMap.size, - participantCount: participantsMap.size, votedUserKeys }; } @@ -132,7 +124,6 @@ export async function createScopedSession({ issueKey, issueId, issueTitle, roomI tenantCloudId, createdAt: Date.now(), state: 'VOTING', - participants: new Map(), votes: new Map(), averageEstimate: null, suggestedEstimate: null, @@ -156,40 +147,11 @@ export async function canAccessSession(sessionId, tenantCloudId) { return session.tenantCloudId === tenantCloudId; } -export async function isSessionParticipant(sessionId, tenantCloudId, userKey) { - const session = await getSession(tenantCloudId, sessionId); - if (!session) return false; - return session.participants.has(userKey); -} - -export async function joinSession({ sessionId, tenantCloudId, userKey, userName, avatarUrl }) { - const result = await withSessionCas(tenantCloudId, sessionId, (session) => { - if (session.tenantCloudId !== tenantCloudId) return undefined; - session.participants.set(userKey, { userKey, userName, avatarUrl }); - session.votes.delete(userKey); - return session; - }); - if (!result) return null; - return getSnapshot(result); -} - -export async function leaveSession({ sessionId, tenantCloudId, userKey }) { - const result = await withSessionCas(tenantCloudId, sessionId, (session) => { - if (session.tenantCloudId !== tenantCloudId) return undefined; - session.participants.delete(userKey); - session.votes.delete(userKey); - return session; - }); - if (!result) return null; - return getSnapshot(result); -} - export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) { const result = await withSessionCas(tenantCloudId, sessionId, (session) => { if (session.state === 'REVEALED' || session.state === 'SAVED') return undefined; if (session.state !== 'VOTING') return undefined; if (session.tenantCloudId !== tenantCloudId) return undefined; - if (!session.participants.has(userKey)) return undefined; const parsed = parseVote(vote); session.votes.set(userKey, parsed.rawValue); return session; @@ -205,10 +167,11 @@ export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) { return getSnapshot(result); } -export async function revealIfComplete(sessionId, tenantCloudId) { +export async function revealIfComplete(sessionId, tenantCloudId, roomMemberCount) { const result = await withSessionCas(tenantCloudId, sessionId, (session) => { - const allVoted = session.participants.size > 0 && - session.votes.size === session.participants.size; + if (session.state !== 'VOTING') return undefined; + const allVoted = roomMemberCount > 0 && + session.votes.size === roomMemberCount; if (!allVoted) return undefined; // no mutation needed @@ -228,8 +191,8 @@ export async function revealIfComplete(sessionId, tenantCloudId) { return session; }); - if (!result) { - // Not all voted — return current snapshot + // withCasRetry returns current value on no-op — check if state actually changed + if (!result || result.state !== 'REVEALED') { const current = await getSession(tenantCloudId, sessionId); if (!current) return null; return { ...getSnapshot(current), allVoted: false }; @@ -244,10 +207,35 @@ export async function revealIfComplete(sessionId, tenantCloudId) { }; } +export async function forceReveal(sessionId, tenantCloudId) { + const result = await withSessionCas(tenantCloudId, sessionId, (session) => { + if (session.state !== 'VOTING') return undefined; + + const numericVotes = [...session.votes.values()] + .map(Number) + .filter(Number.isFinite); + + const average = numericVotes.length + ? numericVotes.reduce((sum, v) => sum + v, 0) / numericVotes.length + : 0; + + session.state = 'REVEALED'; + session.averageEstimate = average; + session.suggestedEstimate = nearestFibonacci(average); + return session; + }); + + if (!result) { + const current = await getSession(tenantCloudId, sessionId); + if (!current) return null; + return getSnapshot(current); + } + return getSnapshot(result); +} + export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) { const result = await withSessionCas(tenantCloudId, sessionId, (session) => { if (session.tenantCloudId !== tenantCloudId) return undefined; - if (!session.participants.has(userKey)) return undefined; session.savedEstimate = estimate; session.state = 'SAVED'; return session; diff --git a/backend/src/services/roomService.js b/backend/src/services/roomService.js index 1b9fa26..910859c 100644 --- a/backend/src/services/roomService.js +++ b/backend/src/services/roomService.js @@ -48,7 +48,8 @@ export async function createRoom(payload) { sprintId, sprintName, createdByAccountId, - createdByName + createdByName, + members: {} }; await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room)); @@ -107,6 +108,52 @@ export async function getRoomById(roomId, cloudId) { return entry ? entry.json() : null; } +export async function joinRoom(roomId, cloudId, { userKey, userName, avatarUrl }) { + assertCloudId(cloudId); + return withCasRetry(kvRooms, roomKey(cloudId, roomId), (raw) => { + if (!raw) return undefined; + const room = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!room.members) room.members = {}; + room.members[userKey] = { userKey, userName, avatarUrl }; + return room; + }); +} + +export async function leaveRoom(roomId, cloudId, userKey) { + assertCloudId(cloudId); + return withCasRetry(kvRooms, roomKey(cloudId, roomId), (raw) => { + if (!raw) return undefined; + const room = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!room.members || !room.members[userKey]) return undefined; + delete room.members[userKey]; + return room; + }); +} + +export async function getRoomMembers(roomId, cloudId) { + assertCloudId(cloudId); + const entry = await kvRooms.get(roomKey(cloudId, roomId)); + if (!entry) return []; + const room = entry.json(); + return Object.values(room.members || {}); +} + +export async function getRoomMemberCount(roomId, cloudId) { + assertCloudId(cloudId); + const entry = await kvRooms.get(roomKey(cloudId, roomId)); + if (!entry) return 0; + const room = entry.json(); + return Object.keys(room.members || {}).length; +} + +export async function isRoomMember(roomId, cloudId, userKey) { + assertCloudId(cloudId); + const entry = await kvRooms.get(roomKey(cloudId, roomId)); + if (!entry) return false; + const room = entry.json(); + return !!(room.members && room.members[userKey]); +} + export async function deleteRoom(roomId, cloudId) { assertCloudId(cloudId); const entry = await kvRooms.get(roomKey(cloudId, roomId)); diff --git a/frontend/public/logo-256.png b/frontend/public/logo-256.png new file mode 100644 index 0000000000000000000000000000000000000000..2309dc579ec3b4901fd6643b3a556d8c13ae533e GIT binary patch literal 5532 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/PokerRoom.jsx b/frontend/src/components/PokerRoom.jsx index c3586a2..be3022d 100644 --- a/frontend/src/components/PokerRoom.jsx +++ b/frontend/src/components/PokerRoom.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { getSocket } from '../services/socket'; const CARDS = [ @@ -17,8 +17,7 @@ const CARDS = [ { value: '100', label: '100', color: '#dc2626' } ]; -export default function PokerRoom({ session, issue, user, onSaved }) { - const [participants, setParticipants] = useState([]); +export default function PokerRoom({ session, issue, user, members, roomId, onSaved }) { const [votedUserKeys, setVotedUserKeys] = useState([]); const [revealed, setRevealed] = useState(false); const [votes, setVotes] = useState({}); @@ -29,15 +28,8 @@ export default function PokerRoom({ session, issue, user, onSaved }) { const [error, setError] = useState(''); const socket = useMemo(() => getSocket(), []); - const joinedRef = useRef(null); useEffect(() => { - function onParticipants(payload) { - if (payload.sessionId !== session.id) return; - setParticipants(payload.participants || []); - setVotedUserKeys(payload.votedUserKeys || []); - } - function onVoteUpdate(payload) { if (payload.sessionId !== session.id) return; setVotedUserKeys(payload.votedUserKeys || []); @@ -63,30 +55,18 @@ export default function PokerRoom({ session, issue, user, onSaved }) { setError(payload?.error || 'Unexpected poker error'); } - socket.on('poker:participants', onParticipants); socket.on('poker:vote-update', onVoteUpdate); socket.on('poker:revealed', onRevealed); socket.on('poker:saved', onSavedPayload); socket.on('poker:error', onError); - // Guard against React strict mode double-invoking effects - if (joinedRef.current !== session.id) { - joinedRef.current = session.id; - socket.emit('poker:join', { - sessionId: session.id, - userKey: user.key, - userName: user.name - }); - } - return () => { - socket.off('poker:participants', onParticipants); socket.off('poker:vote-update', onVoteUpdate); socket.off('poker:revealed', onRevealed); socket.off('poker:saved', onSavedPayload); socket.off('poker:error', onError); }; - }, [session.id, socket, user.key, user.name]); + }, [session.id, socket]); function handleVote(value) { if (revealed) return; @@ -99,7 +79,11 @@ export default function PokerRoom({ session, issue, user, onSaved }) { } function handleKick(userKey) { - socket.emit('poker:kick', { sessionId: session.id, userKey }); + socket.emit('poker:kick', { roomId, userKey }); + } + + function handleReveal() { + socket.emit('poker:reveal', { sessionId: session.id }); } function handleSave() { @@ -145,7 +129,7 @@ export default function PokerRoom({ session, issue, user, onSaved }) { {/* Individual votes */}
{Object.entries(votes).map(([userKey, value]) => { - const name = participants.find((p) => p.userKey === userKey)?.userName || userKey; + const name = members.find((p) => p.userKey === userKey)?.userName || userKey; return (
) : ( -
- {CARDS.map((card) => { - const isSelected = myVote === card.value; - return ( - - ); - })} + + {card.label} + + + ); + })} +
+ {votedUserKeys.length > 0 && ( + + )} )} @@ -212,7 +206,7 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
- {participants.map((participant) => { + {members.map((participant) => { const hasVoted = revealed ? votes[participant.userKey] !== undefined : votedUserKeys.includes(participant.userKey); diff --git a/frontend/src/components/Room.jsx b/frontend/src/components/Room.jsx index 15bcc9f..41bcddb 100644 --- a/frontend/src/components/Room.jsx +++ b/frontend/src/components/Room.jsx @@ -11,21 +11,33 @@ export default function Room({ room, user, dark, toggleDark, onBack }) { const [done, setDone] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [members, setMembers] = useState([]); const issuesRef = useRef([]); const pokerUser = { key: user.jiraAccountId, name: user.displayName }; const socket = useMemo(() => getSocket(), []); - const activeSessionIdRef = useRef(null); - // Emit poker:leave when the Room unmounts (user truly leaves the room) + // Room join/leave lifecycle + members listener useEffect(() => { + function onMembers(payload) { + if (payload.roomId !== room.id) return; + setMembers(payload.members || []); + } + + function onReconnect() { + socket.emit('room:join', { roomId: room.id }); + } + + socket.on('room:members', onMembers); + socket.io.on('reconnect', onReconnect); + socket.emit('room:join', { roomId: room.id }); + return () => { - const sessionId = activeSessionIdRef.current; - if (sessionId) { - socket.emit('poker:leave', { sessionId }); - } + socket.off('room:members', onMembers); + socket.io.off('reconnect', onReconnect); + socket.emit('room:leave', { roomId: room.id }); }; - }, [socket]); + }, [socket, room.id]); useEffect(() => { loadIssues(); @@ -82,7 +94,6 @@ export default function Room({ room, user, dark, toggleDark, onBack }) { const activeSessionRef = useRef(null); activeSessionRef.current = activeSession; - activeSessionIdRef.current = activeSession?.session?.id || null; const advanceToNext = useCallback((estimate) => { if (!activeSessionRef.current) return; @@ -189,6 +200,8 @@ export default function Room({ room, user, dark, toggleDark, onBack }) { session={activeSession.session} issue={activeSession.issue} user={pokerUser} + members={members} + roomId={room.id} onSaved={advanceToNext} />