Add category grouping in game queue and category slide transitions

Questions in the game queue are now grouped by category with per-group
drag-and-drop constraints and category reorder buttons. During live games,
a full-screen category slide appears for 3 seconds when transitioning
between categories. Backend now includes previous_category and
category_changed fields in question_changed events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 14:03:47 -04:00
parent 5df01add6d
commit a997832e2e
3 changed files with 265 additions and 59 deletions

View File

@@ -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')

View File

@@ -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
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,6 +524,55 @@ export default function ContestantView() {
</div>
</div>
{/* Category slide overlay */}
{showCategorySlide && pendingQuestion && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "black",
color: "white",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
animation: "categorySlideIn 0.5s ease-out",
}}
>
<div
style={{
fontSize: "1.2rem",
textTransform: "uppercase",
letterSpacing: "8px",
marginBottom: "1.5rem",
opacity: 0.6,
}}
>
Next Category
</div>
<div
style={{
fontSize: "5rem",
fontWeight: "bold",
textAlign: "center",
padding: "0 2rem",
}}
>
{pendingQuestion.question?.category || "Uncategorized"}
</div>
<style>{`
@keyframes categorySlideIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
</div>
)}
{/* Right side: Scoreboard */}
<div
style={{

View File

@@ -41,6 +41,7 @@ import {
Shuffle,
Trash2,
ChevronDown,
ChevronUp,
ChevronRight,
FileText,
Image,
@@ -67,6 +68,11 @@ interface Template {
total_questions: number;
}
interface CategoryGroup {
category: string;
questionIds: number[];
}
type PoolRow =
| { kind: "category"; category: string; count: number; selectedCount: number }
| { kind: "question"; question: Question; isSelected: boolean };
@@ -114,14 +120,6 @@ function SortableQuestionRow({
<span className="w-5 shrink-0 text-center text-xs tabular-nums text-muted-foreground">
{index + 1}
</span>
{question.category && (
<Badge
variant="outline"
className="shrink-0 text-[0.65rem]"
>
{question.category}
</Badge>
)}
<TypeIcon type={question.type} />
<span className="min-w-0 flex-1 truncate">
{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<CategoryGroup[]>(() => {
const groups: CategoryGroup[] = [];
const catMap = new Map<string, number[]>();
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<string, number[]>();
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) {
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);
@@ -898,28 +969,61 @@ export default function GameSetupView() {
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToParentElement]}
>
<SortableContext
items={selectedQuestions}
strategy={
verticalListSortingStrategy
}
>
<div className="grid gap-1 p-2">
{selectedQuestionObjects.map(
(q, i) => (
{(() => {
let runningIndex = 0;
return groupedQueue.map(
(group, gi) => (
<div key={group.category}>
{/* Category header */}
<div className="flex items-center gap-1 px-1 py-1.5">
<span className="flex-1 text-xs font-semibold text-muted-foreground">
{group.category}
<span className="ml-1 font-normal">
({group.questionIds.length})
</span>
</span>
<button
onClick={() => moveCategoryGroup(gi, -1)}
disabled={gi === 0}
className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
title="Move category up"
>
<ChevronUp className="size-3" />
</button>
<button
onClick={() => moveCategoryGroup(gi, 1)}
disabled={gi === groupedQueue.length - 1}
className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
title="Move category down"
>
<ChevronDown className="size-3" />
</button>
</div>
<SortableContext
items={group.questionIds}
strategy={verticalListSortingStrategy}
>
{group.questionIds.map((id) => {
const q = questions.find((qq) => qq.id === id);
if (!q) return null;
const idx = runningIndex++;
return (
<SortableQuestionRow
key={q.id}
question={q}
index={i}
onRemove={
removeQuestion
}
index={idx}
onRemove={removeQuestion}
/>
),
)}
</div>
);
})}
</SortableContext>
</div>
),
);
})()}
</div>
</DndContext>
)}
</div>