Track participants at room level to fix premature auto-reveal
All checks were successful
Build & Push Container Image / build (push) Successful in 8s
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:
parent
062510b6c6
commit
2d78b9ff07
6 changed files with 247 additions and 190 deletions
|
|
@ -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);
|
||||
const roomId = socket.roomId;
|
||||
if (!roomId) return;
|
||||
try {
|
||||
await leaveSession({
|
||||
sessionId,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId
|
||||
});
|
||||
await emitSessionState(sessionId, socket.user.jiraCloudId);
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
BIN
frontend/public/logo-256.png
Normal file
BIN
frontend/public/logo-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
|
|
@ -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,6 +160,7 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{CARDS.map((card) => {
|
||||
const isSelected = myVote === card.value;
|
||||
|
|
@ -199,6 +184,15 @@ export default function PokerRoom({ session, issue, user, onSaved }) {
|
|||
);
|
||||
})}
|
||||
</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>
|
||||
</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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
return () => {
|
||||
const sessionId = activeSessionIdRef.current;
|
||||
if (sessionId) {
|
||||
socket.emit('poker:leave', { sessionId });
|
||||
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 () => {
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue