Track participants at room level to fix premature auto-reveal
All checks were successful
Build & Push Container Image / build (push) Successful in 8s

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 <noreply@anthropic.com>
This commit is contained in:
Jan Willem Mannaerts 2026-03-04 09:55:49 +01:00
parent 062510b6c6
commit 2d78b9ff07
6 changed files with 247 additions and 190 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -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 */}
<div className="flex flex-wrap gap-2">
{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 (
<span
key={userKey}
@ -176,28 +160,38 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
</div>
</div>
) : (
<div className="grid grid-cols-7 gap-2">
{CARDS.map((card) => {
const isSelected = myVote === card.value;
return (
<button
key={card.value}
onClick={() => handleVote(card.value)}
className="aspect-[2/3] rounded-sm border-2 transition-all flex flex-col items-center justify-center gap-1 hover:scale-105 cursor-pointer bg-transparent"
style={{
borderColor: isSelected ? card.color : 'var(--card-border, #e2e8f0)',
backgroundColor: isSelected ? `${card.color}15` : 'transparent'
}}
>
<span
className="text-base font-bold"
style={{ color: isSelected ? card.color : 'var(--card-text, #475569)' }}
<div className="space-y-3">
<div className="grid grid-cols-7 gap-2">
{CARDS.map((card) => {
const isSelected = myVote === card.value;
return (
<button
key={card.value}
onClick={() => handleVote(card.value)}
className="aspect-[2/3] rounded-sm border-2 transition-all flex flex-col items-center justify-center gap-1 hover:scale-105 cursor-pointer bg-transparent"
style={{
borderColor: isSelected ? card.color : 'var(--card-border, #e2e8f0)',
backgroundColor: isSelected ? `${card.color}15` : 'transparent'
}}
>
{card.label}
</span>
</button>
);
})}
<span
className="text-base font-bold"
style={{ color: isSelected ? card.color : 'var(--card-text, #475569)' }}
>
{card.label}
</span>
</button>
);
})}
</div>
{votedUserKeys.length > 0 && (
<button
onClick={handleReveal}
className="w-full bg-amber-500 hover:bg-amber-400 text-white font-semibold rounded-sm px-4 py-2 transition-colors cursor-pointer border-none text-sm"
>
Reveal Votes ({votedUserKeys.length}/{members.length})
</button>
)}
</div>
)}
</div>
@ -212,7 +206,7 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
</div>
<div className="flex-1 p-4 bg-slate-50 dark:bg-slate-800/50">
<div className="flex flex-col gap-2">
{participants.map((participant) => {
{members.map((participant) => {
const hasVoted = revealed
? votes[participant.userKey] !== undefined
: votedUserKeys.includes(participant.userKey);

View file

@ -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}
/>
</div>