-
-
Question
-
+
+
+ {phase === 'voting' ? (
+ /* ── Voting phase ── */
+
+
+
+
Question
+
+ {question.text}
+
+
+
+
+ {question.answers.map((answer) => (
+
setSelectedId(answer.id)}
+ >
+
+
{answer.text}
+
+ ))}
+
+
+
+
+
+
+
How it works
+
- {question.text}
-
+ Select your answer and submit. Results appear live once
+ you've voted — and you can change your mind until the
+ host closes the question.
+
+
+ ) : (
+ /* ── Waiting phase ── */
+
+
+
+
+
+
-
- {question.answers.map((answer) => (
setSelectedId(answer.id)}
+ style={{
+ fontSize: '15px',
+ fontWeight: 500,
+ color: 'var(--rd-text)',
+ marginBottom: '4px',
+ }}
>
-
-
{answer.text}
+ Vote submitted
+
+
+ Waiting for others…
- ))}
-
-
-
+ {selectedAnswer && (
+
+
+
+ {selectedAnswer.text}
+
+
+ )}
-
-
How it works
-
- Select your answer and submit. Results appear live once
- you've voted — and you can change your mind until the host
- closes the question.
-
-
-
- ) : (
- /* ── Waiting phase ── */
-
-
-
-
-
+
+
+
+
-
+
Live results
+ {question.answers.map((answer, i) => (
+
+ ))}
+
- Vote submitted
-
-
- Waiting for others…
-
-
- {selectedAnswer && (
-
-
-
- {selectedAnswer.text}
-
-
- )}
-
-
-
-
+ Updates live as others vote
+
-
-
-
Live results
- {question.answers.map((answer, i) => (
-
- ))}
-
- Updates live as others vote
-
-
-
- )}
-
-
+ )}
+
+
+ {showThanks &&
}
+ >
);
}
diff --git a/apps/client/src/styles/components.css b/apps/client/src/styles/components.css
index 97f6a81..ffbfd88 100644
--- a/apps/client/src/styles/components.css
+++ b/apps/client/src/styles/components.css
@@ -143,6 +143,10 @@
.btn-ghost:hover {
border-color: var(--rd-muted);
}
+.btn-ghost:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
.btn-sm {
padding: 8px 16px;
@@ -455,6 +459,42 @@
gap: var(--sp-sm);
}
+/* ─── Toast ──────────────────────────────────────────────────── */
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+.toast {
+ position: fixed;
+ top: 32px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--rd-surf);
+ border: 1px solid var(--rd-border);
+ border-radius: var(--r-full);
+ padding: 10px 20px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--rd-text);
+ z-index: 200;
+ animation: toast-in 0.2s ease;
+ white-space: nowrap;
+}
+.toast-icon {
+ display: flex;
+ align-items: center;
+ color: var(--rd-brand);
+}
+
/* ─── Footer ─────────────────────────────────────────────────── */
.footer {
display: flex;
diff --git a/apps/client/src/tests/Host.test.jsx b/apps/client/src/tests/Host.test.jsx
index 2bc1774..26661f5 100644
--- a/apps/client/src/tests/Host.test.jsx
+++ b/apps/client/src/tests/Host.test.jsx
@@ -4,6 +4,17 @@ import socket from '../socket.js';
import Host from '../pages/Host.jsx';
import { getSocketHandler, mockQuestion } from './helpers.js';
+const mockQuestion2 = {
+ id: 'q2',
+ text: 'Tabs or spaces?',
+ answers: [
+ { id: 'a1', text: 'Tabs' },
+ { id: 'a2', text: '2 spaces' },
+ { id: 'a3', text: '4 spaces' },
+ { id: 'a4', text: 'Whatever Prettier decides' },
+ ],
+};
+
// vi.mock must live in this file — Vitest hoists it at parse time.
vi.mock('../socket.js', () => ({
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn(), connect: vi.fn() },
@@ -98,4 +109,27 @@ describe('Host', () => {
render(
);
expect(socket.emit).toHaveBeenCalledWith('join_session', 'XK92PL');
});
+
+ it('emits next_question when Next question is clicked', () => {
+ render(
);
+
+ act(() => getSocketHandler('session_joined')({ question: mockQuestion }));
+
+ fireEvent.click(screen.getByRole('button', { name: /next question/i }));
+
+ expect(socket.emit).toHaveBeenCalledWith('next_question', 'XK92PL');
+ });
+
+ it('updates the displayed question when question_changed fires', () => {
+ render(
);
+
+ act(() => getSocketHandler('session_joined')({ question: mockQuestion }));
+ expect(screen.getByText(mockQuestion.text)).toBeTruthy();
+
+ act(() =>
+ getSocketHandler('question_changed')({ question: mockQuestion2 })
+ );
+
+ expect(screen.getByText(mockQuestion2.text)).toBeTruthy();
+ });
});
diff --git a/apps/client/src/tests/Participant.test.jsx b/apps/client/src/tests/Participant.test.jsx
index 087f0b5..24ca441 100644
--- a/apps/client/src/tests/Participant.test.jsx
+++ b/apps/client/src/tests/Participant.test.jsx
@@ -140,20 +140,54 @@ describe('Participant', () => {
expect(option.classList.contains('selected')).toBe(true);
});
- it('calls onClose when session_closed event fires', () => {
+ it('calls onClose when session_closed event fires', async () => {
const onClose = vi.fn();
render(
);
act(() => getSocketHandler('session_closed')());
- expect(onClose).toHaveBeenCalledOnce();
+ await new Promise((resolve) => setTimeout(resolve, 2500));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('resets to voting phase and clears selection when question_changed fires', () => {
+ renderWithQuestion();
+
+ // Submit a vote to reach the waiting phase
+ fireEvent.click(
+ screen.getByText(mockQuestion.answers[0].text).closest('.answer-opt')
+ );
+ fireEvent.click(screen.getByRole('button', { name: /submit vote/i }));
+ expect(screen.getByText('Vote submitted')).toBeTruthy();
+
+ // Simulate host advancing to the next question
+ act(() =>
+ getSocketHandler('question_changed')({
+ question: {
+ id: 'q2',
+ text: 'New question text',
+ answers: [
+ { id: 'a1', text: 'Option one' },
+ { id: 'a2', text: 'Option two' },
+ { id: 'a3', text: 'Option three' },
+ { id: 'a4', text: 'Option four' },
+ ],
+ },
+ })
+ );
+
+ // Back in voting phase — submit button visible and disabled (no selection)
+ expect(screen.getByRole('button', { name: /submit vote/i }).disabled).toBe(
+ true
+ );
+ expect(screen.getByText('New question text')).toBeTruthy();
});
it('cleans up socket listeners on unmount', () => {
const { unmount } = render(
);
unmount();
- // off() should have been called for each of the 3 registered events
- expect(socket.off).toHaveBeenCalledTimes(3);
+ // off() should have been called for each of the 4 registered events
+ expect(socket.off).toHaveBeenCalledTimes(4);
});
});
diff --git a/apps/server/data/questions.json b/apps/server/data/questions.json
index 1873498..2f0d0ca 100644
--- a/apps/server/data/questions.json
+++ b/apps/server/data/questions.json
@@ -11,16 +11,6 @@
},
{
"id": "q2",
- "text": "Which CSS approach do you actually ship to production?",
- "answers": [
- { "id": "a1", "text": "Plain CSS, I'm not afraid" },
- { "id": "a2", "text": "Tailwind or death" },
- { "id": "a3", "text": "CSS Modules" },
- { "id": "a4", "text": "CSS-in-JS (styled-components / Emotion)" }
- ]
- },
- {
- "id": "q3",
"text": "Tabs or spaces?",
"answers": [
{ "id": "a1", "text": "Tabs, semantic and accessible" },
@@ -28,5 +18,103 @@
{ "id": "a3", "text": "4 spaces, readable code matters" },
{ "id": "a4", "text": "Whatever Prettier decides" }
]
+ },
+ {
+ "id": "q3",
+ "text": "What is your honest testing philosophy?",
+ "answers": [
+ { "id": "a1", "text": "100% coverage or don't even open the PR" },
+ { "id": "a2", "text": "Just 2-3 happy path E2E tests and vibes" },
+ {
+ "id": "a3",
+ "text": "Testing in production is the only way to be sure"
+ },
+ { "id": "a4", "text": "If it builds, it works (TypeScript is my test)" }
+ ]
+ },
+ {
+ "id": "q4",
+ "text": "What's the verdict on Monorepos?",
+ "answers": [
+ { "id": "a1", "text": "Nx/Turborepo or I'm not touching it" },
+ { "id": "a2", "text": "A giant folder of sadness and dependency hell" },
+ { "id": "a3", "text": "Polyrepos: micro-services mean micro-headaches" },
+ {
+ "id": "a4",
+ "text": "One big repo because I'm too lazy to link packages"
+ }
+ ]
+ },
+ {
+ "id": "q5",
+ "text": "What is your actual deployment habit?",
+ "answers": [
+ { "id": "a1", "text": "Full CI/CD pipeline with blue-green deployment" },
+ { "id": "a2", "text": "Git push and pray" },
+ { "id": "a3", "text": "Dragging files via FTP like it's 2005" },
+ {
+ "id": "a4",
+ "text": "Vercel/Netlify auto-deploy (I don't know what a server is)"
+ }
+ ]
+ },
+ {
+ "id": "q6",
+ "text": "What’s your Code Review style?",
+ "answers": [
+ {
+ "id": "a1",
+ "text": "The Nitpicker: 'You missed a semicolon on line 402'"
+ },
+ { "id": "a2", "text": "The Ghost: LGTM without reading a single line" },
+ {
+ "id": "a3",
+ "text": "The Educator: 50 comments on architectural patterns"
+ },
+ { "id": "a4", "text": "The Destroyer: 'Rewrite the whole thing'" }
+ ]
+ },
+ {
+ "id": "q7",
+ "text": "AI Coding Assistants (Copilot/Cursor/ChatGPT)?",
+ "answers": [
+ { "id": "a1", "text": "I literally forgot how to code without it" },
+ { "id": "a2", "text": "I spend more time fixing its bugs than it saves" },
+ { "id": "a3", "text": "Banned by my paranoid CTO" },
+ {
+ "id": "a4",
+ "text": "I only use it for writing unit tests I don't want to do"
+ }
+ ]
+ },
+ {
+ "id": "q8",
+ "text": "What’s your favorite way to debug?",
+ "answers": [
+ { "id": "a1", "text": "console.log('here1'), console.log('here2')" },
+ { "id": "a2", "text": "Actually using the debugger and breakpoints" },
+ { "id": "a3", "text": "Staring at the code until the bug feels guilty" },
+ { "id": "a4", "text": "Deleting the whole file and starting over" }
+ ]
+ },
+ {
+ "id": "q9",
+ "text": "What is the true purpose of a Daily Standup?",
+ "answers": [
+ { "id": "a1", "text": "To prove to management I am actually awake" },
+ { "id": "a2", "text": "To coordinate complex technical blockers" },
+ { "id": "a3", "text": "15 minutes of lying about what I did yesterday" },
+ { "id": "a4", "text": "To listen to the senior talk for 45 minutes" }
+ ]
+ },
+ {
+ "id": "q10",
+ "text": "What is the biggest lie in software development?",
+ "answers": [
+ { "id": "a1", "text": "'I'll add the documentation later'" },
+ { "id": "a2", "text": "'This is a temporary fix'" },
+ { "id": "a3", "text": "'It worked on my machine'" },
+ { "id": "a4", "text": "'This won't break anything'" }
+ ]
}
]
diff --git a/apps/server/src/models/Session.js b/apps/server/src/models/Session.js
index a5e2fc1..f89f548 100644
--- a/apps/server/src/models/Session.js
+++ b/apps/server/src/models/Session.js
@@ -5,6 +5,7 @@ const sessionSchema = new mongoose.Schema(
code: { type: String, required: true, unique: true },
questionId: { type: String, required: true },
status: { type: String, enum: ['open', 'closed'], default: 'open' },
+ usedQuestionIds: { type: [String], default: [] },
},
{ timestamps: true }
);
diff --git a/apps/server/src/routes/sessions.js b/apps/server/src/routes/sessions.js
index 47a11ff..999a90c 100644
--- a/apps/server/src/routes/sessions.js
+++ b/apps/server/src/routes/sessions.js
@@ -24,6 +24,7 @@ export function createSessionsRouter(io) {
const session = new Session({
code: generateCode(),
questionId: question.id,
+ usedQuestionIds: [question.id],
});
await session.save();
diff --git a/apps/server/src/socket/index.js b/apps/server/src/socket/index.js
index ae05e9a..c6dbbd5 100644
--- a/apps/server/src/socket/index.js
+++ b/apps/server/src/socket/index.js
@@ -9,6 +9,13 @@ const questions = JSON.parse(
readFileSync(join(__dirname, '../../data/questions.json'), 'utf-8')
);
+// Returns a random question not yet used in this session, or null if exhausted.
+function pickNextQuestion(usedIds) {
+ const available = questions.filter((q) => !usedIds.includes(q.id));
+ if (available.length === 0) return null;
+ return available[Math.floor(Math.random() * available.length)];
+}
+
export function initSocket(io) {
io.on('connection', (socket) => {
console.log(`socket connected: ${socket.id}`);
@@ -87,6 +94,50 @@ export function initSocket(io) {
}
});
+ // client → server: next_question(code) [host only by convention]
+ // server → room: question_changed(question)
+ socket.on('next_question', async (code) => {
+ try {
+ const session = await Session.findOne({ code });
+
+ if (!session) {
+ socket.emit('error', { message: 'Session not found' });
+ return;
+ }
+
+ if (session.status === 'closed') {
+ socket.emit('error', { message: 'Session is closed' });
+ return;
+ }
+
+ const next = pickNextQuestion(session.usedQuestionIds);
+
+ if (!next) {
+ socket.emit('error', { message: 'No more questions available' });
+ return;
+ }
+
+ // $set + $push in one atomic write — prevents a race where two concurrent
+ // next_question calls both read the same usedQuestionIds and pick the same
+ // question. findOneAndUpdate holds a document-level lock for the duration.
+ await Session.findOneAndUpdate(
+ { code },
+ {
+ $set: { questionId: next.id },
+ $push: { usedQuestionIds: next.id },
+ },
+ { new: true }
+ );
+
+ const hasMore =
+ pickNextQuestion([...session.usedQuestionIds, next.id]) !== null;
+ io.to(code).emit('question_changed', { question: next, hasMore });
+ } catch (err) {
+ console.error('next_question error:', err);
+ socket.emit('error', { message: 'Internal server error' });
+ }
+ });
+
socket.on('disconnect', () => {
console.log(`socket disconnected: ${socket.id}`);
});
diff --git a/apps/server/src/socket/index.test.js b/apps/server/src/socket/index.test.js
index 6d94c87..5a7671e 100644
--- a/apps/server/src/socket/index.test.js
+++ b/apps/server/src/socket/index.test.js
@@ -12,7 +12,10 @@ import { io as ioc } from 'socket.io-client';
// --- DB mocks (must be hoisted before app import) ---
vi.mock('../models/Session.js', () => ({
- default: { findOne: vi.fn() },
+ default: {
+ findOne: vi.fn(),
+ findOneAndUpdate: vi.fn().mockResolvedValue({}),
+ },
}));
vi.mock('../models/Vote.js', () => ({
@@ -144,3 +147,81 @@ describe('submit_vote', () => {
client.disconnect();
});
});
+
+describe('next_question', () => {
+ it('broadcasts question_changed with a new question to the room', async () => {
+ Session.findOne.mockResolvedValue({
+ code: 'EEE555',
+ questionId: 'q1',
+ usedQuestionIds: ['q1'],
+ status: 'open',
+ });
+
+ const client = connect();
+ client.emit('join_session', 'EEE555');
+ await waitFor(client, 'session_joined');
+
+ client.emit('next_question', 'EEE555');
+ const result = await waitFor(client, 'question_changed');
+
+ expect(result.question).toMatchObject({
+ id: expect.not.stringMatching('q1'),
+ text: expect.any(String),
+ });
+ client.disconnect();
+ });
+
+ it('emits error when session is not found', async () => {
+ Session.findOne.mockResolvedValue(null);
+
+ const client = connect();
+ client.emit('next_question', 'NOPE99');
+
+ const result = await waitFor(client, 'error');
+ expect(result.message).toBe('Session not found');
+ client.disconnect();
+ });
+
+ it('emits error when session is closed', async () => {
+ Session.findOne.mockResolvedValue({
+ code: 'FFF666',
+ questionId: 'q1',
+ usedQuestionIds: ['q1'],
+ status: 'closed',
+ });
+
+ const client = connect();
+ client.emit('next_question', 'FFF666');
+
+ const result = await waitFor(client, 'error');
+ expect(result.message).toBe('Session is closed');
+ client.disconnect();
+ });
+
+ it('emits error when the question catalogue is exhausted', async () => {
+ Session.findOne.mockResolvedValue({
+ code: 'GGG777',
+ questionId: 'q10',
+ usedQuestionIds: [
+ 'q1',
+ 'q2',
+ 'q3',
+ 'q4',
+ 'q5',
+ 'q6',
+ 'q7',
+ 'q8',
+ 'q9',
+ 'q10',
+ ],
+ status: 'open',
+ });
+
+ const client = connect();
+ client.emit('next_question', 'GGG777');
+
+ const result = await waitFor(client, 'error');
+ expect(result.message).toBe('No more questions available');
+ client.disconnect();
+ });
+});