diff --git a/backend/services/game_service.py b/backend/services/game_service.py
index 893d581..34a387c 100644
--- a/backend/services/game_service.py
+++ b/backend/services/game_service.py
@@ -106,18 +106,31 @@ def broadcast_question_change(game, socketio_instance):
if not current_question:
return
+ # Determine previous category for category slide transitions
+ prev_category = None
+ if game.current_question_index > 0:
+ prev_gq = game.game_questions[game.current_question_index - 1]
+ prev_category = prev_gq.question.category
+
+ current_category = current_question.category
+ category_changed = current_category != prev_category
+
# Emit to contestant room (without answer)
socketio_instance.emit('question_changed', {
'question_index': game.current_question_index,
'question': current_question.to_dict(include_answer=False),
- 'total_questions': len(game.game_questions)
+ 'total_questions': len(game.game_questions),
+ 'previous_category': prev_category,
+ 'category_changed': category_changed
}, room=f'game_{game.id}_contestant')
# Emit to admin room (with answer)
socketio_instance.emit('question_with_answer', {
'question_index': game.current_question_index,
'question': current_question.to_dict(include_answer=True),
- 'total_questions': len(game.game_questions)
+ 'total_questions': len(game.game_questions),
+ 'previous_category': prev_category,
+ 'category_changed': category_changed
}, room=f'game_{game.id}_admin')
diff --git a/frontend/frontend/src/components/contestant/ContestantView.jsx b/frontend/frontend/src/components/contestant/ContestantView.jsx
index b4188c8..072865a 100644
--- a/frontend/frontend/src/components/contestant/ContestantView.jsx
+++ b/frontend/frontend/src/components/contestant/ContestantView.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { useSocket } from "../../hooks/useSocket";
import { gamesAPI } from "../../services/api";
@@ -23,6 +23,12 @@ export default function ContestantView() {
const [questionIndex, setQuestionIndex] = useState(0);
const [totalQuestions, setTotalQuestions] = useState(0);
+ // Category slide state
+ const [previousCategory, setPreviousCategory] = useState(null);
+ const [showCategorySlide, setShowCategorySlide] = useState(false);
+ const [pendingQuestion, setPendingQuestion] = useState(null);
+ const categorySlideTimeoutRef = useRef(null);
+
useEffect(() => {
// Load initial game state
const loadGameState = async () => {
@@ -107,14 +113,41 @@ export default function ContestantView() {
socket.on("question_changed", (data) => {
console.log("Question changed:", data);
- setCurrentQuestion(data.question);
- setQuestionIndex(data.question_index);
- setTotalQuestions(data.total_questions);
- setShowAnswer(false); // Hide answer when question changes
- setAnswer("");
- setTimerSeconds(30);
- setTimerActive(true);
- setTimerPaused(false);
+
+ const applyQuestion = (questionData) => {
+ setCurrentQuestion(questionData.question);
+ setQuestionIndex(questionData.question_index);
+ setTotalQuestions(questionData.total_questions);
+ setShowAnswer(false);
+ setAnswer("");
+ setTimerSeconds(30);
+ setTimerActive(true);
+ setTimerPaused(false);
+ setPreviousCategory(questionData.question?.category || null);
+ };
+
+ // Clear any existing category slide timeout
+ if (categorySlideTimeoutRef.current) {
+ clearTimeout(categorySlideTimeoutRef.current);
+ categorySlideTimeoutRef.current = null;
+ }
+
+ if (data.category_changed) {
+ // Category changed — show category slide, then apply question after 3s
+ setPendingQuestion(data);
+ setShowCategorySlide(true);
+ categorySlideTimeoutRef.current = setTimeout(() => {
+ setShowCategorySlide(false);
+ setPendingQuestion(null);
+ applyQuestion(data);
+ categorySlideTimeoutRef.current = null;
+ }, 3000);
+ } else {
+ // Same category — apply immediately
+ setShowCategorySlide(false);
+ setPendingQuestion(null);
+ applyQuestion(data);
+ }
});
socket.on("score_updated", (data) => {
@@ -149,6 +182,13 @@ export default function ContestantView() {
setTimerPaused(false);
setCurrentTurnTeamId(null);
setCurrentTurnTeamName(null);
+ setPreviousCategory(null);
+ setShowCategorySlide(false);
+ setPendingQuestion(null);
+ if (categorySlideTimeoutRef.current) {
+ clearTimeout(categorySlideTimeoutRef.current);
+ categorySlideTimeoutRef.current = null;
+ }
});
socket.on("lifeline_updated", (data) => {
@@ -484,7 +524,56 @@ export default function ContestantView() {
- {/* Right side: Scoreboard */}
+ {/* Category slide overlay */}
+ {showCategorySlide && pendingQuestion && (
+
+
+ Next Category
+
+
+ {pendingQuestion.question?.category || "Uncategorized"}
+
+
+
+ )}
+
+ {/* Right side: Scoreboard */}
{index + 1}
- {question.category && (
-
- {question.category}
-
- )}
{question.type !== "text" && question.answer
@@ -295,6 +293,26 @@ export default function GameSetupView() {
[selectedQuestions, questions],
);
+ /* ─── Grouped queue (by category, preserving first-appearance order) ── */
+ const groupedQueue = useMemo(() => {
+ const groups: CategoryGroup[] = [];
+ const catMap = new Map();
+ const catOrder: string[] = [];
+ for (const id of selectedQuestions) {
+ const q = questions.find((qq) => qq.id === id);
+ const cat = q?.category || "Uncategorized";
+ if (!catMap.has(cat)) {
+ catMap.set(cat, []);
+ catOrder.push(cat);
+ }
+ catMap.get(cat)!.push(id);
+ }
+ for (const cat of catOrder) {
+ groups.push({ category: cat, questionIds: catMap.get(cat)! });
+ }
+ return groups;
+ }, [selectedQuestions, questions]);
+
/* ─── Pool virtualizer ─────────────────────────────────────── */
const poolVirtualizer = useVirtualizer({
count: poolRows.length,
@@ -306,19 +324,49 @@ export default function GameSetupView() {
overscan: 20,
});
- /* ─── Queue virtualizer ────────────────────────────────────── */
- const queueVirtualizer = useVirtualizer({
- count: selectedQuestionObjects.length,
- getScrollElement: () => queueScrollRef.current,
- estimateSize: () => 34,
- overscan: 10,
- });
+ /* ─── Utility: sort IDs by category grouping ──────────────── */
+ const sortByCategory = useCallback(
+ (ids: number[]) => {
+ const groups = new Map();
+ const order: string[] = [];
+ for (const id of ids) {
+ const q = questions.find((qq) => qq.id === id);
+ const cat = q?.category || "Uncategorized";
+ if (!groups.has(cat)) {
+ groups.set(cat, []);
+ order.push(cat);
+ }
+ groups.get(cat)!.push(id);
+ }
+ return order.flatMap((cat) => groups.get(cat)!);
+ },
+ [questions],
+ );
/* ─── Question actions ─────────────────────────────────────── */
const addQuestion = (id: number) => {
- if (!selectedQuestions.includes(id)) {
- setSelectedQuestions((prev) => [...prev, id]);
- }
+ if (selectedQuestions.includes(id)) return;
+ const q = questions.find((qq) => qq.id === id);
+ const cat = q?.category || "Uncategorized";
+
+ setSelectedQuestions((prev) => {
+ // Find last index of a question in the same category
+ let insertAt = -1;
+ for (let i = prev.length - 1; i >= 0; i--) {
+ const existing = questions.find((qq) => qq.id === prev[i]);
+ if ((existing?.category || "Uncategorized") === cat) {
+ insertAt = i + 1;
+ break;
+ }
+ }
+ if (insertAt === -1) {
+ // New category — append to end
+ return [...prev, id];
+ }
+ const next = [...prev];
+ next.splice(insertAt, 0, id);
+ return next;
+ });
};
const removeQuestion = (id: number) => {
@@ -346,13 +394,32 @@ export default function GameSetupView() {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
- if (over && active.id !== over.id) {
- setSelectedQuestions((items) => {
- const oldIndex = items.indexOf(active.id as number);
- const newIndex = items.indexOf(over.id as number);
- return arrayMove(items, oldIndex, newIndex);
- });
- }
+ if (!over || active.id === over.id) return;
+
+ // Verify same category
+ const activeQ = questions.find((q) => q.id === active.id);
+ const overQ = questions.find((q) => q.id === over.id);
+ const activeCat = activeQ?.category || "Uncategorized";
+ const overCat = overQ?.category || "Uncategorized";
+ if (activeCat !== overCat) return;
+
+ setSelectedQuestions((items) => {
+ const oldIndex = items.indexOf(active.id as number);
+ const newIndex = items.indexOf(over.id as number);
+ return arrayMove(items, oldIndex, newIndex);
+ });
+ };
+
+ /* ─── Category group reorder ──────────────────────────────── */
+ const moveCategoryGroup = (categoryIndex: number, direction: -1 | 1) => {
+ const newIndex = categoryIndex + direction;
+ if (newIndex < 0 || newIndex >= groupedQueue.length) return;
+
+ const newGroups = [...groupedQueue];
+ const [moved] = newGroups.splice(categoryIndex, 1);
+ newGroups.splice(newIndex, 0, moved);
+
+ setSelectedQuestions(newGroups.flatMap((g) => g.questionIds));
};
/* ─── Random questions ─────────────────────────────────────── */
@@ -390,7 +457,9 @@ export default function GameSetupView() {
}
if (newQuestionIds.length > 0) {
- setSelectedQuestions((prev) => [...prev, ...newQuestionIds]);
+ setSelectedQuestions((prev) =>
+ sortByCategory([...prev, ...newQuestionIds]),
+ );
}
setRandomSelections({});
setShowRandomPanel(false);
@@ -423,7 +492,9 @@ export default function GameSetupView() {
);
return [...prev, ...newQs];
});
- setSelectedQuestions(templateQuestions.map((q) => q.id));
+ setSelectedQuestions(
+ sortByCategory(templateQuestions.map((q) => q.id)),
+ );
setTemplateDialogOpen(false);
} catch (error) {
console.error("Error loading template:", error);
@@ -899,27 +970,60 @@ export default function GameSetupView() {
onDragEnd={handleDragEnd}
modifiers={[restrictToParentElement]}
>
-
-
- {selectedQuestionObjects.map(
- (q, i) => (
-
+
+ {(() => {
+ let runningIndex = 0;
+ return groupedQueue.map(
+ (group, gi) => (
+
+ {/* Category header */}
+
+
+ {group.category}
+
+ ({group.questionIds.length})
+
+
+
+
+
+
+ {group.questionIds.map((id) => {
+ const q = questions.find((qq) => qq.id === id);
+ if (!q) return null;
+ const idx = runningIndex++;
+ return (
+
+ );
+ })}
+
+
),
- )}
-
-
+ );
+ })()}
+
)}