From 5eec69b8ae086d317f84982bc47f6bb529346088 Mon Sep 17 00:00:00 2001 From: s3bc40 Date: Sun, 12 Apr 2026 18:07:09 +0200 Subject: [PATCH 1/5] feat: up to 10 questions fo server data --- apps/server/data/questions.json | 108 +++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 10 deletions(-) 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'" } + ] } ] From 53b185d7f44b983382729a8e62ead9c57ff09578 Mon Sep 17 00:00:00 2001 From: s3bc40 Date: Sun, 12 Apr 2026 18:09:58 +0200 Subject: [PATCH 2/5] feat(server): add next question socket handler --- apps/server/src/models/Session.js | 1 + apps/server/src/routes/sessions.js | 1 + apps/server/src/socket/index.js | 51 +++++++++++++++++ apps/server/src/socket/index.test.js | 83 +++++++++++++++++++++++++++- 4 files changed, 135 insertions(+), 1 deletion(-) 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(); + }); +}); From b99c463724992baed27d500332ff7a7446fe0b10 Mon Sep 17 00:00:00 2001 From: s3bc40 Date: Sun, 12 Apr 2026 18:11:26 +0200 Subject: [PATCH 3/5] feat(client): add toast comp and css to participant when done --- apps/client/src/components/Toast.jsx | 12 ++ apps/client/src/pages/Participant.jsx | 255 ++++++++++++++------------ apps/client/src/styles/components.css | 40 ++++ 3 files changed, 189 insertions(+), 118 deletions(-) create mode 100644 apps/client/src/components/Toast.jsx diff --git a/apps/client/src/components/Toast.jsx b/apps/client/src/components/Toast.jsx new file mode 100644 index 0000000..46e62c5 --- /dev/null +++ b/apps/client/src/components/Toast.jsx @@ -0,0 +1,12 @@ +import { LuCheck } from 'react-icons/lu'; + +export default function Toast({ message }) { + return ( +
+ + + + {message} +
+ ); +} diff --git a/apps/client/src/pages/Participant.jsx b/apps/client/src/pages/Participant.jsx index 08eef87..ad12d10 100644 --- a/apps/client/src/pages/Participant.jsx +++ b/apps/client/src/pages/Participant.jsx @@ -3,14 +3,25 @@ import confetti from 'canvas-confetti'; import { LuClock, LuSend, LuRefreshCw } from 'react-icons/lu'; import Layout from '../components/Layout'; import ResultBar from '../components/ResultBar'; +import Toast from '../components/Toast'; import { useSession } from '../hooks/useSession'; import { BAR_COLORS, calcPct } from '../utils'; import socket from '../socket'; export default function Participant({ code, onClose }) { - const { question, results } = useSession(code, onClose); const [selectedId, setSelectedId] = useState(null); const [phase, setPhase] = useState('voting'); + const [showThanks, setShowThanks] = useState(false); + + function handleClose() { + setShowThanks(true); + setTimeout(onClose, 2500); + } + + const { question, results } = useSession(code, handleClose, () => { + setPhase('voting'); + setSelectedId(null); + }); function handleSubmit() { socket.emit('submit_vote', { code, answerId: selectedId }); @@ -46,139 +57,147 @@ export default function Participant({ code, onClose }) { } return ( - -
- {phase === 'voting' ? ( - /* ── Voting phase ── */ -
-
-
-
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 ── */ -
-
-
-
-
+