Compare commits

..

No commits in common. "fc270d437f5bcefdf2ee81cb85c31eac06063038" and "90fba17a1ee3313eea12c4437737b08a2714184c" have entirely different histories.

24 changed files with 204 additions and 3137 deletions

11
App.tsx
View file

@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { useAuth } from 'react-oidc-context';
import { useGame } from './hooks/useGame';
import { useQuizLibrary } from './hooks/useQuizLibrary';
import { useUserConfig } from './hooks/useUserConfig';
import { Landing } from './components/Landing';
import { Lobby } from './components/Lobby';
import { GameScreen } from './components/GameScreen';
@ -13,7 +12,7 @@ import { RevealScreen } from './components/RevealScreen';
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
import { QuizEditor } from './components/QuizEditor';
import { SaveOptionsModal } from './components/SaveOptionsModal';
import type { Quiz, GameConfig } from './types';
import type { Quiz } from './types';
const seededRandom = (seed: number) => {
const x = Math.sin(seed * 9999) * 10000;
@ -43,7 +42,6 @@ const FloatingShapes = React.memo(() => {
function App() {
const auth = useAuth();
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
const { defaultConfig } = useUserConfig();
const [showSaveOptions, setShowSaveOptions] = useState(false);
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
const {
@ -76,8 +74,7 @@ function App() {
sourceQuizId,
updateQuizFromEditor,
startGameFromEditor,
backFromEditor,
gameConfig
backFromEditor
} = useGame();
const handleSaveQuiz = async () => {
@ -155,8 +152,7 @@ function App() {
onSave={handleEditorSave}
onStartGame={startGameFromEditor}
onBack={backFromEditor}
showSaveButton={auth.isAuthenticated}
defaultConfig={defaultConfig}
sourceQuizId={sourceQuizId}
/>
) : null}
@ -199,7 +195,6 @@ function App() {
onAnswer={handleAnswer}
hasAnswered={hasAnswered}
lastPointsEarned={lastPointsEarned}
hostPlays={gameConfig.hostParticipates}
/>
)
) : null}

View file

@ -1,95 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { X, Settings, Loader2 } from 'lucide-react';
import { GameConfigPanel } from './GameConfigPanel';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import type { GameConfig } from '../types';
interface DefaultConfigModalProps {
isOpen: boolean;
onClose: () => void;
config: GameConfig;
onChange: (config: GameConfig) => void;
onSave: () => void;
saving: boolean;
}
export const DefaultConfigModal: React.FC<DefaultConfigModalProps> = ({
isOpen,
onClose,
config,
onChange,
onSave,
saving,
}) => {
useBodyScrollLock(isOpen);
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-2xl max-w-lg w-full shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-theme-primary to-purple-600">
<div className="flex items-center gap-3 text-white">
<div className="p-2 bg-white/20 rounded-xl">
<Settings size={24} />
</div>
<div>
<h2 className="text-xl font-black">Default Game Settings</h2>
<p className="text-sm opacity-80">Applied to all new quizzes</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-xl transition text-white"
>
<X size={24} />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1 bg-gray-50">
<GameConfigPanel
config={config}
onChange={onChange}
questionCount={10}
/>
</div>
<div className="p-6 border-t border-gray-100 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-3 rounded-xl font-bold border-2 border-gray-200 text-gray-600 hover:bg-gray-50 transition"
>
Cancel
</button>
<button
onClick={onSave}
disabled={saving}
className="flex-1 py-3 rounded-xl font-bold bg-theme-primary text-white hover:bg-theme-primary/90 transition disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving ? (
<>
<Loader2 size={20} className="animate-spin" />
Saving...
</>
) : (
'Save Defaults'
)}
</button>
</div>
</motion.div>
</motion.div>
);
};

View file

@ -1,325 +0,0 @@
import React, { useState } from 'react';
import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp } from 'lucide-react';
import type { GameConfig } from '../types';
interface GameConfigPanelProps {
config: GameConfig;
onChange: (config: GameConfig) => void;
questionCount: number;
compact?: boolean;
}
interface TooltipProps {
content: string;
}
const Tooltip: React.FC<TooltipProps> = ({ content }) => {
const [show, setShow] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const buttonRef = React.useRef<HTMLButtonElement>(null);
const updatePosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setCoords({
top: rect.top + rect.height / 2,
left: rect.right + 8,
});
}
};
const handleMouseEnter = () => {
updatePosition();
setShow(true);
};
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
updatePosition();
setShow(!show);
};
return (
<div className="relative inline-block">
<button
ref={buttonRef}
type="button"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShow(false)}
onClick={handleClick}
className="p-1 text-gray-400 hover:text-gray-600 transition"
>
<Info size={16} />
</button>
{show && (
<div
className="fixed z-[100] w-64 p-3 text-sm bg-gray-900 text-white rounded-lg shadow-lg"
style={{
top: coords.top,
left: coords.left,
transform: 'translateY(-50%)',
}}
>
{content}
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1 w-2 h-2 bg-gray-900 rotate-45" />
</div>
)}
</div>
);
};
interface ToggleRowProps {
icon: React.ReactNode;
iconActive: boolean;
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
tooltip?: string;
children?: React.ReactNode;
}
const ToggleRow: React.FC<ToggleRowProps> = ({
icon,
iconActive,
label: labelText,
description,
checked,
onChange,
tooltip,
children,
}) => {
const inputId = React.useId();
return (
<div className="bg-white rounded-xl border-2 border-gray-200 overflow-hidden">
<div className="flex items-center justify-between p-4 hover:bg-gray-50 transition group">
<label htmlFor={inputId} className="flex items-center gap-3 cursor-pointer flex-1">
<div className={`p-2 rounded-lg transition ${iconActive ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
{icon}
</div>
<div>
<p className="font-bold text-gray-900">{labelText}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
</label>
<div className="flex items-center gap-2">
{tooltip && <Tooltip content={tooltip} />}
<label htmlFor={inputId} className="relative cursor-pointer">
<input
id={inputId}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors" />
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5" />
</label>
</div>
</div>
{checked && children && (
<div className="px-4 pb-4 pt-0 border-t border-gray-100 bg-gray-50">
{children}
</div>
)}
</div>
);
};
interface NumberInputProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
suffix?: string;
}
const NumberInput: React.FC<NumberInputProps> = ({ label, value, onChange, min, max, step = 1, suffix }) => (
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium text-gray-700">{label}</span>
<div className="flex items-center gap-2">
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
step={step}
className="w-20 px-3 py-1.5 border-2 border-gray-200 rounded-lg text-center font-bold text-gray-900 bg-white focus:border-theme-primary focus:ring-2 focus:ring-theme-primary/20 outline-none"
/>
{suffix && <span className="text-sm text-gray-500">{suffix}</span>}
</div>
</div>
);
export const GameConfigPanel: React.FC<GameConfigPanelProps> = ({
config,
onChange,
questionCount,
compact = false,
}) => {
const [expanded, setExpanded] = useState(!compact);
const update = (partial: Partial<GameConfig>) => {
onChange({ ...config, ...partial });
};
const suggestedComebackBonus = Math.round(50 + (questionCount * 5));
const suggestedFirstCorrectBonus = Math.round(25 + (questionCount * 2.5));
if (compact && !expanded) {
return (
<button
onClick={() => setExpanded(true)}
className="w-full flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 hover:border-theme-primary transition"
>
<span className="font-bold text-gray-700">Game Settings</span>
<ChevronDown size={20} className="text-gray-400" />
</button>
);
}
return (
<div className="space-y-2">
{compact && (
<button
onClick={() => setExpanded(false)}
className="w-full flex items-center justify-between p-3 bg-gray-100 rounded-xl hover:bg-gray-200 transition"
>
<span className="font-bold text-gray-700">Game Settings</span>
<ChevronUp size={20} className="text-gray-400" />
</button>
)}
<ToggleRow
icon={<Shuffle size={20} />}
iconActive={config.shuffleQuestions}
label="Shuffle Questions"
description="Randomize question order when starting"
checked={config.shuffleQuestions}
onChange={(v) => update({ shuffleQuestions: v })}
/>
<ToggleRow
icon={<Shuffle size={20} />}
iconActive={config.shuffleAnswers}
label="Shuffle Answers"
description="Randomize answer positions for each question"
checked={config.shuffleAnswers}
onChange={(v) => update({ shuffleAnswers: v })}
/>
<ToggleRow
icon={<Eye size={20} />}
iconActive={config.hostParticipates}
label="Host Participates"
description="Join as a player and answer questions"
checked={config.hostParticipates}
onChange={(v) => update({ hostParticipates: v })}
/>
<ToggleRow
icon={<Flame size={20} />}
iconActive={config.streakBonusEnabled}
label="Streak Bonus"
description="Multiply points for consecutive correct answers"
checked={config.streakBonusEnabled}
onChange={(v) => update({ streakBonusEnabled: v })}
tooltip={`After ${config.streakThreshold} correct answers in a row, points are multiplied by ${config.streakMultiplier}x. The multiplier increases by ${((config.streakMultiplier - 1) * 100).toFixed(0)}% for each additional correct answer.`}
>
<div className="mt-3 space-y-1">
<NumberInput
label="Streak threshold"
value={config.streakThreshold}
onChange={(v) => update({ streakThreshold: v })}
min={2}
max={10}
suffix="correct"
/>
<NumberInput
label="Multiplier"
value={config.streakMultiplier}
onChange={(v) => update({ streakMultiplier: v })}
min={1.05}
max={2}
step={0.05}
suffix="x"
/>
</div>
</ToggleRow>
<ToggleRow
icon={<TrendingUp size={20} />}
iconActive={config.comebackBonusEnabled}
label="Comeback Bonus"
description="Extra points for players not in top 3"
checked={config.comebackBonusEnabled}
onChange={(v) => update({ comebackBonusEnabled: v })}
>
<div className="mt-3">
<NumberInput
label="Bonus points"
value={config.comebackBonusPoints}
onChange={(v) => update({ comebackBonusPoints: v })}
min={10}
max={500}
suffix="pts"
/>
<p className="text-xs text-gray-500 mt-1">
Suggested for {questionCount} questions: {suggestedComebackBonus} pts
</p>
</div>
</ToggleRow>
<ToggleRow
icon={<MinusCircle size={20} />}
iconActive={config.penaltyForWrongAnswer}
label="Wrong Answer Penalty"
description="Deduct points for incorrect answers"
checked={config.penaltyForWrongAnswer}
onChange={(v) => update({ penaltyForWrongAnswer: v })}
>
<div className="mt-3">
<NumberInput
label="Penalty"
value={config.penaltyPercent}
onChange={(v) => update({ penaltyPercent: v })}
min={5}
max={100}
suffix="%"
/>
<p className="text-xs text-gray-500 mt-1">
Deducts {config.penaltyPercent}% of max points ({Math.round(1000 * config.penaltyPercent / 100)} pts)
</p>
</div>
</ToggleRow>
<ToggleRow
icon={<Award size={20} />}
iconActive={config.firstCorrectBonusEnabled}
label="First Correct Bonus"
description="Extra points for first player to answer correctly"
checked={config.firstCorrectBonusEnabled}
onChange={(v) => update({ firstCorrectBonusEnabled: v })}
>
<div className="mt-3">
<NumberInput
label="Bonus points"
value={config.firstCorrectBonusPoints}
onChange={(v) => update({ firstCorrectBonusPoints: v })}
min={10}
max={500}
suffix="pts"
/>
<p className="text-xs text-gray-500 mt-1">
Suggested for {questionCount} questions: {suggestedFirstCorrectBonus} pts
</p>
</div>
</ToggleRow>
</div>
);
};

View file

@ -13,7 +13,6 @@ interface GameScreenProps {
onAnswer: (isCorrect: boolean) => void;
hasAnswered: boolean;
lastPointsEarned: number | null;
hostPlays?: boolean;
}
export const GameScreen: React.FC<GameScreenProps> = ({
@ -25,10 +24,8 @@ export const GameScreen: React.FC<GameScreenProps> = ({
role,
onAnswer,
hasAnswered,
hostPlays = true,
}) => {
const isClient = role === 'CLIENT';
const isSpectator = role === 'HOST' && !hostPlays;
const displayOptions = question?.options || [];
const timeLeftSeconds = Math.ceil(timeLeft / 1000);
@ -54,7 +51,7 @@ export const GameScreen: React.FC<GameScreenProps> = ({
</div>
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
{isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
{isClient ? 'Controller' : 'Host'}
</div>
</div>
@ -83,14 +80,12 @@ export const GameScreen: React.FC<GameScreenProps> = ({
let opacityClass = "opacity-100";
let scaleClass = "scale-100";
let cursorClass = "";
if (isSpectator) {
cursorClass = "cursor-default";
} else if (hasAnswered) {
// If answering phase and user answered, dim everything
if (hasAnswered) {
opacityClass = "opacity-50 cursor-not-allowed grayscale";
scaleClass = "scale-95";
}
}
return (
<motion.button
@ -98,14 +93,14 @@ export const GameScreen: React.FC<GameScreenProps> = ({
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }}
disabled={hasAnswered || isSpectator}
onClick={() => !isSpectator && onAnswer(option as any)}
disabled={hasAnswered}
onClick={() => onAnswer(option as any)}
className={`
${colorClass} ${opacityClass} ${scaleClass} ${cursorClass}
${colorClass} ${opacityClass} ${scaleClass}
rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)]
flex flex-col md:flex-row items-center justify-center md:justify-start
p-4 md:p-8
${!isSpectator ? 'active:shadow-none active:translate-y-[8px] active:scale-95' : ''}
active:shadow-none active:translate-y-[8px] active:scale-95
transition-all duration-300 relative group overflow-hidden border-b-8 border-black/10
`}
>

View file

@ -1,13 +1,11 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } from 'lucide-react';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } from 'lucide-react';
import { useAuth } from 'react-oidc-context';
import { AuthButton } from './AuthButton';
import { QuizLibrary } from './QuizLibrary';
import { DefaultConfigModal } from './DefaultConfigModal';
import { useQuizLibrary } from '../hooks/useQuizLibrary';
import { useUserConfig } from '../hooks/useUserConfig';
import type { Quiz, GameConfig } from '../types';
import type { Quiz } from '../types';
type GenerateMode = 'topic' | 'document';
@ -33,14 +31,10 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const [questionCount, setQuestionCount] = useState(10);
const [isDragging, setIsDragging] = useState(false);
const [useOcr, setUseOcr] = useState(false);
const [defaultConfigOpen, setDefaultConfigOpen] = useState(false);
const [editingDefaultConfig, setEditingDefaultConfig] = useState<GameConfig | null>(null);
const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/'));
const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type));
const showOcrOption = hasImageFile || hasDocumentFile;
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
const {
quizzes,
@ -138,19 +132,7 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center relative">
<div className="absolute top-4 right-4 flex items-center gap-2">
{auth.isAuthenticated && (
<button
onClick={() => {
setEditingDefaultConfig(defaultConfig);
setDefaultConfigOpen(true);
}}
className="p-2.5 bg-white/90 hover:bg-white rounded-xl shadow-md hover:shadow-lg transition-all text-gray-600 hover:text-theme-primary"
title="Default Game Settings"
>
<Settings size={20} />
</button>
)}
<div className="absolute top-4 right-4">
<AuthButton />
</div>
<motion.div
@ -434,24 +416,6 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
onDeleteQuiz={deleteQuiz}
onRetry={retryLibrary}
/>
<DefaultConfigModal
isOpen={defaultConfigOpen}
onClose={() => {
setDefaultConfigOpen(false);
setEditingDefaultConfig(null);
}}
config={editingDefaultConfig || defaultConfig}
onChange={setEditingDefaultConfig}
onSave={async () => {
if (editingDefaultConfig) {
await saveDefaultConfig(editingDefaultConfig);
setDefaultConfigOpen(false);
setEditingDefaultConfig(null);
}
}}
saving={savingConfig}
/>
</div>
);
};

View file

@ -1,52 +1,41 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react';
import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } from 'lucide-react';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } from '../types';
import { Quiz, Question } from '../types';
import { SortableQuestionCard } from './SortableQuestionCard';
import { QuestionEditModal } from './QuestionEditModal';
import { GameConfigPanel } from './GameConfigPanel';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { v4 as uuidv4 } from 'uuid';
interface QuizEditorProps {
quiz: Quiz;
onSave: (quiz: Quiz) => void;
onStartGame: (quiz: Quiz, config: GameConfig) => void;
onConfigChange?: (config: GameConfig) => void;
onStartGame: (quiz: Quiz) => void;
onBack: () => void;
showSaveButton?: boolean;
isSaving?: boolean;
defaultConfig?: GameConfig;
}
export const QuizEditor: React.FC<QuizEditorProps> = ({
quiz: initialQuiz,
onSave,
onStartGame,
onConfigChange,
onBack,
showSaveButton = true,
isSaving,
defaultConfig,
isSaving
}) => {
const [quiz, setQuiz] = useState<Quiz>(initialQuiz);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [titleEditing, setTitleEditing] = useState(false);
const [config, setConfig] = useState<GameConfig>(
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
);
const [shuffleQuestions, setShuffleQuestions] = useState(false);
const [shuffleAnswers, setShuffleAnswers] = useState(false);
useBodyScrollLock(!!showDeleteConfirm);
const handleConfigChange = useCallback((newConfig: GameConfig) => {
setConfig(newConfig);
onConfigChange?.(newConfig);
}, [onConfigChange]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }
@ -128,11 +117,11 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
const handleStartGame = () => {
let questions = [...quiz.questions];
if (config.shuffleQuestions) {
if (shuffleQuestions) {
questions = questions.sort(() => Math.random() - 0.5);
}
if (config.shuffleAnswers) {
if (shuffleAnswers) {
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
@ -149,7 +138,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
});
}
onStartGame({ ...quiz, questions, config }, config);
onStartGame({ ...quiz, questions });
};
return (
@ -253,12 +242,51 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
</div>
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
<GameConfigPanel
config={config}
onChange={handleConfigChange}
questionCount={quiz.questions.length}
compact
/>
<div className="space-y-2">
<label className="flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 cursor-pointer hover:border-theme-primary transition group">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition ${shuffleQuestions ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
<Shuffle size={20} />
</div>
<div>
<p className="font-bold text-gray-900">Shuffle Questions</p>
<p className="text-sm text-gray-500">Randomize question order when starting</p>
</div>
</div>
<div className="relative">
<input
type="checkbox"
checked={shuffleQuestions}
onChange={(e) => setShuffleQuestions(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors"></div>
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5"></div>
</div>
</label>
<label className="flex items-center justify-between p-4 bg-white rounded-xl border-2 border-gray-200 cursor-pointer hover:border-theme-primary transition group">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition ${shuffleAnswers ? 'bg-theme-primary text-white' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
<Shuffle size={20} />
</div>
<div>
<p className="font-bold text-gray-900">Shuffle Answers</p>
<p className="text-sm text-gray-500">Randomize answer positions for each question</p>
</div>
</div>
<div className="relative">
<input
type="checkbox"
checked={shuffleAnswers}
onChange={(e) => setShuffleAnswers(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-theme-primary transition-colors"></div>
<div className="absolute left-0.5 top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform peer-checked:translate-x-5"></div>
</div>
</label>
</div>
<button
onClick={handleStartGame}

View file

@ -1,11 +1,17 @@
import React, { useState, useEffect } from 'react';
import { Player, PointsBreakdown } from '../types';
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react';
import { Player } from '../types';
import { motion, useSpring, useTransform } from 'framer-motion';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, LabelList } from 'recharts';
import { Loader2 } from 'lucide-react';
import { PlayerAvatar } from './PlayerAvatar';
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => {
const spring = useSpring(0, { duration });
const AnimatedScoreLabel: React.FC<{
x: number;
y: number;
width: number;
value: number;
}> = ({ x, y, width, value }) => {
const spring = useSpring(0, { duration: 500 });
const display = useTransform(spring, (latest) => Math.round(latest));
const [displayValue, setDisplayValue] = useState(0);
@ -15,169 +21,18 @@ const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value,
return () => unsubscribe();
}, [value, spring, display]);
return <span>{displayValue}</span>;
};
interface BonusBadgeProps {
points: number;
label: string;
icon: React.ReactNode;
color: string;
delay: number;
}
const BonusBadge: React.FC<BonusBadgeProps> = ({ points, label, icon, color, delay }) => (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay, type: 'spring', stiffness: 500, damping: 15 }}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-white font-bold text-sm ${color}`}
>
{icon}
<span>+{points}</span>
<span className="text-white/80">{label}</span>
</motion.div>
);
const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, delay }) => (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay, type: 'spring', stiffness: 500, damping: 15 }}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-red-500 text-white font-bold text-sm"
>
<X size={14} />
<span>-{points}</span>
</motion.div>
);
interface PlayerRowProps {
player: Player & { displayName: string };
index: number;
maxScore: number;
}
const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
const [phase, setPhase] = useState(0);
const breakdown = player.pointsBreakdown;
const baseDelay = index * 0.3;
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
timers.push(setTimeout(() => setPhase(1), (baseDelay + 0.2) * 1000));
if (breakdown) {
if (breakdown.streakBonus > 0) timers.push(setTimeout(() => setPhase(2), (baseDelay + 0.8) * 1000));
if (breakdown.comebackBonus > 0) timers.push(setTimeout(() => setPhase(3), (baseDelay + 1.2) * 1000));
if (breakdown.firstCorrectBonus > 0) timers.push(setTimeout(() => setPhase(4), (baseDelay + 1.6) * 1000));
}
return () => timers.forEach(clearTimeout);
}, [baseDelay, breakdown]);
const getDisplayScore = () => {
if (!breakdown) return player.previousScore;
let score = player.previousScore;
if (phase >= 1) score += breakdown.basePoints - breakdown.penalty;
if (phase >= 2) score += breakdown.streakBonus;
if (phase >= 3) score += breakdown.comebackBonus;
if (phase >= 4) score += breakdown.firstCorrectBonus;
return Math.max(0, score);
};
const barWidth = maxScore > 0 ? (getDisplayScore() / maxScore) * 100 : 0;
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: baseDelay, duration: 0.4 }}
className="flex items-center gap-4 py-3"
<text
x={x + width + 15}
y={y}
dy={35}
fill="black"
fontSize={24}
fontWeight={900}
fontFamily="Fredoka"
>
<div className="flex items-center gap-3 w-48 shrink-0">
<PlayerAvatar seed={player.avatarSeed} size={32} />
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
</div>
<div className="flex-1 flex items-center gap-4">
<div className="flex-1 h-12 bg-gray-100 rounded-full overflow-hidden relative">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: player.color }}
initial={{ width: 0 }}
animate={{ width: `${Math.max(barWidth, 2)}%` }}
transition={{ duration: 0.6, delay: baseDelay + 0.1 }}
/>
</div>
<div className="w-20 text-right">
<span className="font-black text-2xl font-display">
<AnimatedNumber value={getDisplayScore()} />
</span>
</div>
</div>
<div className="flex items-center gap-2 min-w-[280px] justify-start">
<AnimatePresence>
{breakdown === null && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: baseDelay + 0.3 }}
className="text-gray-400 font-medium text-sm"
>
No answer
</motion.span>
)}
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
<PenaltyBadge points={breakdown.penalty} delay={0} />
)}
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-green-500 text-white font-bold text-sm"
>
<span>+{breakdown.basePoints}</span>
</motion.div>
)}
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
<BonusBadge
points={breakdown.streakBonus}
label="Streak"
icon={<Flame size={14} />}
color="bg-amber-500"
delay={0}
/>
)}
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
<BonusBadge
points={breakdown.comebackBonus}
label="Comeback"
icon={<Rocket size={14} />}
color="bg-blue-500"
delay={0}
/>
)}
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
<BonusBadge
points={breakdown.firstCorrectBonus}
label="First!"
icon={<Zap size={14} />}
color="bg-yellow-500"
delay={0}
/>
)}
</AnimatePresence>
</div>
</motion.div>
{displayValue}
</text>
);
};
@ -194,23 +49,59 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
}));
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score).slice(0, 5);
const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1);
return (
<div className="flex flex-col h-screen p-8">
<header className="text-center mb-8">
<header className="text-center mb-12">
<h1 className="text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
</header>
<div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-8 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-8 border-white/50 overflow-hidden">
<div className="space-y-2">
{sortedPlayers.map((player, index) => (
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
<div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-12 flex text-gray-900 max-w-6xl w-full mx-auto relative z-10 border-8 border-white/50">
<div className="flex flex-col justify-around py-4 pr-4">
{sortedPlayers.map((player) => (
<div key={player.id} className="flex items-center gap-3 h-[50px]">
<PlayerAvatar seed={player.avatarSeed} size={24} />
<span className="font-black text-xl font-display whitespace-nowrap">{player.displayName}</span>
</div>
))}
</div>
<div className="flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={sortedPlayers}
layout="vertical"
margin={{ top: 20, right: 100, left: 0, bottom: 5 }}
>
<XAxis type="number" hide />
<YAxis type="category" dataKey="displayName" hide />
<Bar dataKey="score" radius={[0, 20, 20, 0]} barSize={50} animationDuration={1500}>
{sortedPlayers.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color}
className="filter drop-shadow-md"
/>
))}
<LabelList
dataKey="score"
position="right"
offset={15}
content={({ x, y, width, value }) => (
<AnimatedScoreLabel
x={x as number}
y={y as number}
width={width as number}
value={value as number}
/>
)}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="mt-8 flex justify-end max-w-5xl w-full mx-auto">
<div className="mt-8 flex justify-end max-w-6xl w-full mx-auto">
{isHost ? (
<button
onClick={onNext}
@ -220,11 +111,11 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
</button>
) : (
<div className="flex items-center gap-3 bg-white/10 px-8 py-4 rounded-2xl backdrop-blur-md border-2 border-white/20">
<Loader2 className="animate-spin w-8 h-8" />
<span className="text-xl font-bold">Waiting for host...</span>
<Loader2 className="animate-spin w-8 h-8" />
<span className="text-xl font-bold">Waiting for host...</span>
</div>
)}
</div>
</div>
);
};
};

View file

@ -1,5 +1,4 @@
import { Triangle, Diamond, Circle, Square } from 'lucide-react';
import type { GameConfig, Player, PointsBreakdown } from './types';
export const COLORS = {
red: 'bg-red-600',
@ -26,79 +25,6 @@ export const QUESTION_TIME = 20; // seconds
export const QUESTION_TIME_MS = 20000; // milliseconds
export const POINTS_PER_QUESTION = 1000;
export const calculateBasePoints = (timeLeftMs: number, questionTimeMs: number, maxPoints: number = POINTS_PER_QUESTION): number => {
const responseTimeMs = questionTimeMs - timeLeftMs;
const responseTimeSec = responseTimeMs / 1000;
const questionTimeSec = questionTimeMs / 1000;
if (responseTimeSec < 0.5) {
return maxPoints;
}
return Math.round((1 - (responseTimeSec / questionTimeSec) / 2) * maxPoints);
};
interface PointsCalculationParams {
isCorrect: boolean;
timeLeftMs: number;
questionTimeMs: number;
streak: number;
playerRank: number;
isFirstCorrect: boolean;
config: GameConfig;
}
export const calculatePointsWithBreakdown = (params: PointsCalculationParams): PointsBreakdown => {
const { isCorrect, timeLeftMs, questionTimeMs, streak, playerRank, isFirstCorrect, config } = params;
const breakdown: PointsBreakdown = {
basePoints: 0,
streakBonus: 0,
comebackBonus: 0,
firstCorrectBonus: 0,
penalty: 0,
total: 0,
};
if (!isCorrect) {
if (config.penaltyForWrongAnswer) {
breakdown.penalty = Math.round(POINTS_PER_QUESTION * (config.penaltyPercent / 100));
breakdown.total = -breakdown.penalty;
}
return breakdown;
}
breakdown.basePoints = calculateBasePoints(timeLeftMs, questionTimeMs);
let pointsAfterStreak = breakdown.basePoints;
if (config.streakBonusEnabled && streak >= config.streakThreshold) {
const streakCount = streak - config.streakThreshold;
const multiplier = config.streakMultiplier + (streakCount * (config.streakMultiplier - 1));
pointsAfterStreak = Math.round(breakdown.basePoints * multiplier);
breakdown.streakBonus = pointsAfterStreak - breakdown.basePoints;
}
if (config.comebackBonusEnabled && playerRank > 3) {
breakdown.comebackBonus = config.comebackBonusPoints;
}
if (config.firstCorrectBonusEnabled && isFirstCorrect) {
breakdown.firstCorrectBonus = config.firstCorrectBonusPoints;
}
breakdown.total = pointsAfterStreak + breakdown.comebackBonus + breakdown.firstCorrectBonus;
return breakdown;
};
export const calculatePoints = (params: PointsCalculationParams): number => {
return calculatePointsWithBreakdown(params).total;
};
export const getPlayerRank = (playerId: string, players: Player[]): number => {
const sorted = [...players].sort((a, b) => b.score - a.score);
return sorted.findIndex(p => p.id === playerId) + 1;
};
export const PLAYER_COLORS = [
'#2563eb',
'#e21b3c',

View file

@ -1,13 +1,10 @@
import { useAuth } from 'react-oidc-context';
import { useCallback, useRef } from 'react';
import { useCallback } from 'react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
let isRedirecting = false;
export const useAuthenticatedFetch = () => {
const auth = useAuth();
const silentRefreshInProgress = useRef<Promise<string> | null>(null);
const isTokenExpired = useCallback(() => {
if (!auth.user?.expires_at) return true;
@ -17,58 +14,28 @@ export const useAuthenticatedFetch = () => {
return now >= expiresAt - bufferMs;
}, [auth.user?.expires_at]);
const redirectToLogin = useCallback(() => {
if (isRedirecting) return;
isRedirecting = true;
auth.signinRedirect();
}, [auth]);
const attemptSilentRefresh = useCallback(async (): Promise<string | null> => {
if (silentRefreshInProgress.current) {
return silentRefreshInProgress.current;
}
silentRefreshInProgress.current = (async () => {
try {
const user = await auth.signinSilent();
return user?.access_token || null;
} catch {
return null;
} finally {
silentRefreshInProgress.current = null;
}
})() as Promise<string>;
return silentRefreshInProgress.current;
}, [auth]);
const ensureValidToken = useCallback(async (): Promise<string> => {
if (isRedirecting) {
throw new Error('Session expired, redirecting to login');
}
if (!auth.user?.access_token) {
throw new Error('Not authenticated');
}
if (isTokenExpired()) {
const newToken = await attemptSilentRefresh();
if (newToken) {
return newToken;
try {
const user = await auth.signinSilent();
if (user?.access_token) {
return user.access_token;
}
} catch {
auth.signinRedirect();
throw new Error('Session expired, redirecting to login');
}
redirectToLogin();
throw new Error('Session expired, redirecting to login');
}
return auth.user.access_token;
}, [auth.user?.access_token, isTokenExpired, attemptSilentRefresh, redirectToLogin]);
}, [auth, isTokenExpired]);
const authFetch = useCallback(
async (path: string, options: RequestInit = {}): Promise<Response> => {
if (isRedirecting) {
throw new Error('Session expired, redirecting to login');
}
if (!navigator.onLine) {
throw new Error('You appear to be offline. Please check your connection.');
}
@ -94,18 +61,21 @@ export const useAuthenticatedFetch = () => {
}
if (response.status === 401) {
const newToken = await attemptSilentRefresh();
if (newToken) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`,
'Content-Type': 'application/json',
},
});
try {
const user = await auth.signinSilent();
if (user?.access_token) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${user.access_token}`,
'Content-Type': 'application/json',
},
});
}
} catch {
auth.signinRedirect();
}
redirectToLogin();
throw new Error('Session expired, redirecting to login');
}
@ -115,7 +85,7 @@ export const useAuthenticatedFetch = () => {
return response;
},
[ensureValidToken, attemptSilentRefresh, redirectToLogin]
[auth, ensureValidToken]
);
return {

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument } from '../types';
import { generateQuiz } from '../services/geminiService';
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
import { POINTS_PER_QUESTION, QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS } from '../constants';
import { Peer, DataConnection } from 'peerjs';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
@ -25,8 +25,6 @@ export const useGame = () => {
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
const [gameConfig, setGameConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peerRef = useRef<Peer | null>(null);
@ -38,13 +36,11 @@ export const useGame = () => {
const playersRef = useRef<Player[]>([]);
const currentQuestionIndexRef = useRef(0);
const quizRef = useRef<Quiz | null>(null);
const gameConfigRef = useRef<GameConfig>(DEFAULT_GAME_CONFIG);
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
useEffect(() => { playersRef.current = players; }, [players]);
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
useEffect(() => { quizRef.current = quiz; }, [quiz]);
useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]);
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
@ -127,10 +123,9 @@ export const useGame = () => {
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
};
const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => {
const startGameFromEditor = (finalQuiz: Quiz) => {
setQuiz(finalQuiz);
setGameConfig(config);
initializeHostGame(finalQuiz, config.hostParticipates);
initializeHostGame(finalQuiz);
};
const backFromEditor = () => {
@ -144,7 +139,7 @@ export const useGame = () => {
// This prevents stale closures in the PeerJS event listeners
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
const initializeHostGame = (newQuiz: Quiz, hostParticipates: boolean = true) => {
const initializeHostGame = (newQuiz: Quiz) => {
setQuiz(newQuiz);
const pin = generateGamePin();
setGamePin(pin);
@ -153,27 +148,19 @@ export const useGame = () => {
peerRef.current = peer;
peer.on('open', (id) => {
if (hostParticipates) {
const hostPlayer: Player = {
id: 'host',
name: 'Host',
score: 0,
previousScore: 0,
streak: 0,
lastAnswerCorrect: null,
pointsBreakdown: null,
isBot: false,
avatarSeed: Math.random(),
color: PLAYER_COLORS[0]
};
setPlayers([hostPlayer]);
setCurrentPlayerId('host');
setCurrentPlayerName('Host');
} else {
setPlayers([]);
setCurrentPlayerId(null);
setCurrentPlayerName(null);
}
const hostPlayer: Player = {
id: 'host',
name: 'Host',
score: 0,
streak: 0,
lastAnswerCorrect: null,
isBot: false,
avatarSeed: Math.random(),
color: PLAYER_COLORS[0]
};
setPlayers([hostPlayer]);
setCurrentPlayerId('host');
setCurrentPlayerName('Host');
setGameState('LOBBY');
});
@ -199,10 +186,8 @@ export const useGame = () => {
id: conn.peer,
name: data.payload.name,
score: 0,
previousScore: 0,
streak: 0,
lastAnswerCorrect: null,
pointsBreakdown: null,
isBot: false,
avatarSeed: Math.random(),
color: PLAYER_COLORS[colorIndex]
@ -220,31 +205,15 @@ export const useGame = () => {
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
if (isFirstCorrect) {
setFirstCorrectPlayerId(playerId);
}
const newStreak = isCorrect ? currentPlayer.streak + 1 : 0;
const playerRank = getPlayerRank(playerId, playersRef.current);
const breakdown = calculatePointsWithBreakdown({
isCorrect,
timeLeftMs: timeLeftRef.current,
questionTimeMs: QUESTION_TIME_MS,
streak: newStreak,
playerRank,
isFirstCorrect,
config: gameConfigRef.current,
});
const newScore = Math.max(0, currentPlayer.score + breakdown.total);
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0;
const newScore = currentPlayer.score + points;
setPlayers(prev => prev.map(p => {
if (p.id !== playerId) return p;
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown };
return { ...p, score: newScore, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect };
}));
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } });
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } });
}
};
@ -287,8 +256,7 @@ export const useGame = () => {
setLastPointsEarned(null);
setSelectedOption(null);
setTimeLeft(QUESTION_TIME_MS);
setFirstCorrectPlayerId(null);
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null })));
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null })));
const currentQuiz = quizRef.current;
const currentIndex = currentQuestionIndexRef.current;
@ -453,44 +421,24 @@ export const useGame = () => {
const handleAnswer = (arg: boolean | AnswerOption) => {
if (hasAnswered || gameState !== 'QUESTION') return;
if (role === 'HOST' && !gameConfigRef.current.hostParticipates) return;
setHasAnswered(true);
if (role === 'HOST') {
const option = arg as AnswerOption;
const isCorrect = option.isCorrect;
setSelectedOption(option);
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0;
setLastPointsEarned(points);
const hostPlayer = playersRef.current.find(p => p.id === 'host');
const currentStrk = hostPlayer?.streak || 0;
const newStreak = isCorrect ? currentStrk + 1 : 0;
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
if (isFirstCorrect) {
setFirstCorrectPlayerId('host');
}
const playerRank = getPlayerRank('host', playersRef.current);
const breakdown = calculatePointsWithBreakdown({
isCorrect,
timeLeftMs: timeLeftRef.current,
questionTimeMs: QUESTION_TIME_MS,
streak: newStreak,
playerRank,
isFirstCorrect,
config: gameConfigRef.current,
});
setLastPointsEarned(breakdown.total);
const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total);
const newScore = (hostPlayer?.score || 0) + points;
const newStreak = isCorrect ? (hostPlayer?.streak || 0) + 1 : 0;
setCurrentPlayerScore(newScore);
setCurrentStreak(newStreak);
setPlayers(prev => prev.map(p => {
if (p.id !== 'host') return p;
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown };
return { ...p, score: newScore, streak: newStreak, lastAnswerCorrect: isCorrect };
}));
} else {
const option = arg as AnswerOption;
@ -514,7 +462,7 @@ export const useGame = () => {
}, []);
return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
pendingQuizToSave, dismissSavePrompt, sourceQuizId,
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
updateQuizFromEditor, startGameFromEditor, backFromEditor

View file

@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from 'react';
import toast from 'react-hot-toast';
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig } from '../types';
import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types';
interface UseQuizLibraryReturn {
quizzes: QuizListItem[];
@ -14,7 +14,6 @@ interface UseQuizLibraryReturn {
loadQuiz: (id: string) => Promise<SavedQuiz>;
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
updateQuizConfig: (id: string, config: GameConfig) => Promise<void>;
deleteQuiz: (id: string) => Promise<void>;
retry: () => Promise<void>;
clearError: () => void;
@ -127,7 +126,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
title: quiz.title,
source,
aiTopic,
gameConfig: quiz.config,
questions: quiz.questions.map(q => ({
text: q.text,
timeLimit: q.timeLimit,
@ -172,7 +170,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
method: 'PUT',
body: JSON.stringify({
title: quiz.title,
gameConfig: quiz.config,
questions: quiz.questions.map(q => ({
text: q.text,
timeLimit: q.timeLimit,
@ -206,25 +203,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
}
}, [authFetch]);
const updateQuizConfig = useCallback(async (id: string, config: GameConfig): Promise<void> => {
try {
const response = await authFetch(`/api/quizzes/${id}/config`, {
method: 'PATCH',
body: JSON.stringify({ gameConfig: config }),
});
if (!response.ok) {
throw new Error('Failed to update config');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update config';
if (!message.includes('redirecting')) {
toast.error(message);
}
throw err;
}
}, [authFetch]);
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
setDeletingQuizId(id);
setError(null);
@ -275,7 +253,6 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
loadQuiz,
saveQuiz,
updateQuiz,
updateQuizConfig,
deleteQuiz,
retry,
clearError,

View file

@ -1,73 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import toast from 'react-hot-toast';
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
import type { GameConfig } from '../types';
import { DEFAULT_GAME_CONFIG } from '../types';
interface UseUserConfigReturn {
defaultConfig: GameConfig;
loading: boolean;
saving: boolean;
fetchDefaultConfig: () => Promise<void>;
saveDefaultConfig: (config: GameConfig) => Promise<void>;
}
export const useUserConfig = (): UseUserConfigReturn => {
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
const [defaultConfig, setDefaultConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const fetchDefaultConfig = useCallback(async () => {
if (!isAuthenticated) return;
setLoading(true);
try {
const response = await authFetch('/api/users/me');
if (response.ok) {
const data = await response.json();
if (data.defaultGameConfig) {
setDefaultConfig(data.defaultGameConfig);
}
}
} catch {
} finally {
setLoading(false);
}
}, [authFetch, isAuthenticated]);
const saveDefaultConfig = useCallback(async (config: GameConfig) => {
setSaving(true);
try {
const response = await authFetch('/api/users/me/default-config', {
method: 'PUT',
body: JSON.stringify({ defaultGameConfig: config }),
});
if (!response.ok) {
throw new Error('Failed to save defaults');
}
setDefaultConfig(config);
toast.success('Default settings saved!');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save defaults';
toast.error(message);
throw err;
} finally {
setSaving(false);
}
}, [authFetch]);
useEffect(() => {
fetchDefaultConfig();
}, [fetchDefaultConfig]);
return {
defaultConfig,
loading,
saving,
fetchDefaultConfig,
saveDefaultConfig,
};
};

View file

@ -16,24 +16,4 @@ db.pragma('foreign_keys = ON');
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
db.exec(schema);
const runMigrations = () => {
const tableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
const hasGameConfig = tableInfo.some(col => col.name === 'game_config');
if (!hasGameConfig) {
db.exec("ALTER TABLE quizzes ADD COLUMN game_config TEXT");
console.log("Migration: Added game_config to quizzes");
}
const userTableInfo = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
const hasDefaultConfig = userTableInfo.some(col => col.name === 'default_game_config');
if (!hasDefaultConfig) {
db.exec("ALTER TABLE users ADD COLUMN default_game_config TEXT");
console.log("Migration: Added default_game_config to users");
}
};
runMigrations();
console.log(`Database initialized at ${DB_PATH}`);

View file

@ -1,8 +0,0 @@
-- Migration: Add game_config columns
-- Run these statements to migrate existing databases
-- Add game_config to quizzes table
ALTER TABLE quizzes ADD COLUMN game_config TEXT;
-- Add default_game_config to users table
ALTER TABLE users ADD COLUMN default_game_config TEXT;

View file

@ -4,8 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
default_game_config TEXT
last_login DATETIME
);
CREATE TABLE IF NOT EXISTS quizzes (
@ -14,7 +13,6 @@ CREATE TABLE IF NOT EXISTS quizzes (
title TEXT NOT NULL,
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
ai_topic TEXT,
game_config TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)

View file

@ -7,26 +7,10 @@ const router = Router();
router.use(requireAuth);
interface GameConfig {
shuffleQuestions: boolean;
shuffleAnswers: boolean;
hostParticipates: boolean;
streakBonusEnabled: boolean;
streakThreshold: number;
streakMultiplier: number;
comebackBonusEnabled: boolean;
comebackBonusPoints: number;
penaltyForWrongAnswer: boolean;
penaltyPercent: number;
firstCorrectBonusEnabled: boolean;
firstCorrectBonusPoints: number;
}
interface QuizBody {
title: string;
source: 'manual' | 'ai_generated';
aiTopic?: string;
gameConfig?: GameConfig;
questions: {
text: string;
timeLimit?: number;
@ -60,7 +44,7 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
const quiz = db.prepare(`
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, created_at as createdAt, updated_at as updatedAt
SELECT id, title, source, ai_topic as aiTopic, created_at as createdAt, updated_at as updatedAt
FROM quizzes
WHERE id = ? AND user_id = ?
`).get(req.params.id, req.user!.sub) as Record<string, unknown> | undefined;
@ -94,18 +78,8 @@ router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
};
});
let parsedConfig = null;
if (quiz.gameConfig && typeof quiz.gameConfig === 'string') {
try {
parsedConfig = JSON.parse(quiz.gameConfig);
} catch {
parsedConfig = null;
}
}
res.json({
...quiz,
gameConfig: parsedConfig,
questions: questionsWithOptions,
});
});
@ -144,7 +118,7 @@ function validateQuizBody(body: QuizBody): string | null {
router.post('/', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, source, aiTopic, gameConfig, questions } = body;
const { title, source, aiTopic, questions } = body;
const validationError = validateQuizBody(body);
if (validationError) {
@ -164,8 +138,8 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
`);
const insertQuiz = db.prepare(`
INSERT INTO quizzes (id, user_id, title, source, ai_topic, game_config)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO quizzes (id, user_id, title, source, ai_topic)
VALUES (?, ?, ?, ?, ?)
`);
const insertQuestion = db.prepare(`
@ -186,7 +160,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
req.user!.name || null
);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : null);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null);
questions.forEach((q, qIdx) => {
const questionId = uuidv4();
@ -213,7 +187,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, questions, gameConfig } = body;
const { title, questions } = body;
const quizId = req.params.id;
if (!title?.trim()) {
@ -253,7 +227,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
}
const updateQuiz = db.prepare(`
UPDATE quizzes SET title = ?, game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`);
const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`);
@ -269,7 +243,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
`);
const transaction = db.transaction(() => {
updateQuiz.run(title, gameConfig ? JSON.stringify(gameConfig) : null, quizId);
updateQuiz.run(title, quizId);
deleteQuestions.run(quizId);
questions.forEach((q, qIdx) => {
@ -295,26 +269,6 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
res.json({ id: quizId });
});
router.patch('/:id/config', (req: AuthenticatedRequest, res: Response) => {
const quizId = req.params.id;
const { gameConfig } = req.body as { gameConfig: GameConfig };
const existing = db.prepare(`
SELECT id FROM quizzes WHERE id = ? AND user_id = ?
`).get(quizId, req.user!.sub);
if (!existing) {
res.status(404).json({ error: 'Quiz not found' });
return;
}
db.prepare(`
UPDATE quizzes SET game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`).run(gameConfig ? JSON.stringify(gameConfig) : null, quizId);
res.json({ success: true });
});
router.delete('/:id', (req: AuthenticatedRequest, res: Response) => {
const result = db.prepare(`
DELETE FROM quizzes WHERE id = ? AND user_id = ?

View file

@ -8,7 +8,7 @@ router.use(requireAuth);
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(`
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, created_at as createdAt, last_login as lastLogin
SELECT id, username, email, display_name as displayName, created_at as createdAt, last_login as lastLogin
FROM users
WHERE id = ?
`).get(req.user!.sub) as Record<string, unknown> | undefined;
@ -19,7 +19,6 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
username: req.user!.preferred_username,
email: req.user!.email,
displayName: req.user!.name,
defaultGameConfig: null,
createdAt: null,
lastLogin: null,
isNew: true,
@ -27,41 +26,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
return;
}
let parsedConfig = null;
if (user.defaultGameConfig && typeof user.defaultGameConfig === 'string') {
try {
parsedConfig = JSON.parse(user.defaultGameConfig);
} catch {
parsedConfig = null;
}
}
res.json({ ...user, defaultGameConfig: parsedConfig, isNew: false });
});
router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
const { defaultGameConfig } = req.body;
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, default_game_config, last_login)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
default_game_config = ?,
last_login = CURRENT_TIMESTAMP
`);
const configJson = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null;
upsertUser.run(
req.user!.sub,
req.user!.preferred_username,
req.user!.email || null,
req.user!.name || null,
configJson,
configJson
);
res.json({ success: true });
res.json({ ...user, isNew: false });
});
export default router;

View file

@ -1257,271 +1257,6 @@ async function runTests() {
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
console.log('\nGame Config Tests:');
let gameConfigQuizId: string | null = null;
await test('POST /api/quizzes with gameConfig saves config', async () => {
const quizWithConfig = {
title: 'Quiz With Game Config',
source: 'manual',
gameConfig: {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: false,
streakBonusEnabled: true,
streakThreshold: 5,
streakMultiplier: 1.5,
comebackBonusEnabled: true,
comebackBonusPoints: 100,
penaltyForWrongAnswer: true,
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
},
questions: [
{
text: 'Config test question?',
timeLimit: 20,
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', quizWithConfig, 201);
gameConfigQuizId = (data as { id: string }).id;
});
await test('GET /api/quizzes/:id returns gameConfig', async () => {
if (!gameConfigQuizId) throw new Error('No game config quiz created');
const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`);
const quiz = data as Record<string, unknown>;
if (!quiz.gameConfig) throw new Error('Missing gameConfig');
const config = quiz.gameConfig as Record<string, unknown>;
if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not preserved');
if (config.shuffleAnswers !== true) throw new Error('shuffleAnswers not preserved');
if (config.hostParticipates !== false) throw new Error('hostParticipates not preserved');
if (config.streakBonusEnabled !== true) throw new Error('streakBonusEnabled not preserved');
if (config.streakThreshold !== 5) throw new Error('streakThreshold not preserved');
if (config.streakMultiplier !== 1.5) throw new Error('streakMultiplier not preserved');
if (config.comebackBonusEnabled !== true) throw new Error('comebackBonusEnabled not preserved');
if (config.comebackBonusPoints !== 100) throw new Error('comebackBonusPoints not preserved');
if (config.penaltyForWrongAnswer !== true) throw new Error('penaltyForWrongAnswer not preserved');
if (config.penaltyPercent !== 30) throw new Error('penaltyPercent not preserved');
if (config.firstCorrectBonusEnabled !== true) throw new Error('firstCorrectBonusEnabled not preserved');
if (config.firstCorrectBonusPoints !== 75) throw new Error('firstCorrectBonusPoints not preserved');
});
await test('PUT /api/quizzes/:id updates gameConfig', async () => {
if (!gameConfigQuizId) throw new Error('No game config quiz created');
const updatedQuiz = {
title: 'Updated Config Quiz',
gameConfig: {
shuffleQuestions: false,
shuffleAnswers: false,
hostParticipates: true,
streakBonusEnabled: false,
streakThreshold: 3,
streakMultiplier: 1.1,
comebackBonusEnabled: false,
comebackBonusPoints: 50,
penaltyForWrongAnswer: false,
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
},
questions: [
{
text: 'Updated question?',
options: [
{ text: 'X', isCorrect: true, shape: 'circle', color: 'yellow' },
{ text: 'Y', isCorrect: false, shape: 'square', color: 'green' },
],
},
],
};
await request('PUT', `/api/quizzes/${gameConfigQuizId}`, updatedQuiz);
const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`);
const quiz = data as Record<string, unknown>;
const config = quiz.gameConfig as Record<string, unknown>;
if (config.shuffleQuestions !== false) throw new Error('shuffleQuestions not updated');
if (config.hostParticipates !== true) throw new Error('hostParticipates not updated');
if (config.streakBonusEnabled !== false) throw new Error('streakBonusEnabled not updated');
});
await test('PATCH /api/quizzes/:id/config updates only gameConfig', async () => {
if (!gameConfigQuizId) throw new Error('No game config quiz created');
const newConfig = {
gameConfig: {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: true,
streakBonusEnabled: true,
streakThreshold: 4,
streakMultiplier: 1.3,
comebackBonusEnabled: true,
comebackBonusPoints: 150,
penaltyForWrongAnswer: true,
penaltyPercent: 20,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 100,
},
};
await request('PATCH', `/api/quizzes/${gameConfigQuizId}/config`, newConfig);
const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`);
const quiz = data as Record<string, unknown>;
const config = quiz.gameConfig as Record<string, unknown>;
if (config.shuffleQuestions !== true) throw new Error('PATCH did not update shuffleQuestions');
if (config.streakThreshold !== 4) throw new Error('PATCH did not update streakThreshold');
if (config.comebackBonusPoints !== 150) throw new Error('PATCH did not update comebackBonusPoints');
if (quiz.title !== 'Updated Config Quiz') throw new Error('PATCH should not have changed title');
});
await test('PATCH /api/quizzes/:id/config with non-existent ID returns 404', async () => {
const config = {
gameConfig: {
shuffleQuestions: true,
shuffleAnswers: false,
hostParticipates: true,
streakBonusEnabled: false,
streakThreshold: 3,
streakMultiplier: 1.1,
comebackBonusEnabled: false,
comebackBonusPoints: 50,
penaltyForWrongAnswer: false,
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
},
};
await request('PATCH', '/api/quizzes/non-existent-id/config', config, 404);
});
await test('PATCH /api/quizzes/:id/config with null gameConfig clears config', async () => {
if (!gameConfigQuizId) throw new Error('No game config quiz created');
await request('PATCH', `/api/quizzes/${gameConfigQuizId}/config`, { gameConfig: null });
const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`);
const quiz = data as Record<string, unknown>;
if (quiz.gameConfig !== null) throw new Error('gameConfig should be null after clearing');
});
await test('POST /api/quizzes without gameConfig sets null config', async () => {
const quizNoConfig = {
title: 'Quiz Without Config',
source: 'manual',
questions: [
{
text: 'No config question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', quizNoConfig, 201);
const quizId = (data as { id: string }).id;
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
const quiz = getResult as Record<string, unknown>;
if (quiz.gameConfig !== null) throw new Error('Expected null gameConfig for quiz without config');
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('DELETE cleanup game config quiz', async () => {
if (gameConfigQuizId) {
await request('DELETE', `/api/quizzes/${gameConfigQuizId}`, undefined, 204);
}
});
console.log('\nUser Default Config Tests:');
await test('GET /api/users/me returns defaultGameConfig', async () => {
const { data } = await request('GET', '/api/users/me');
const user = data as Record<string, unknown>;
if (!('defaultGameConfig' in user)) throw new Error('Missing defaultGameConfig field');
});
await test('PUT /api/users/me/default-config saves default config', async () => {
const defaultConfig = {
defaultGameConfig: {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: false,
streakBonusEnabled: true,
streakThreshold: 4,
streakMultiplier: 1.25,
comebackBonusEnabled: true,
comebackBonusPoints: 75,
penaltyForWrongAnswer: true,
penaltyPercent: 15,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 60,
},
};
await request('PUT', '/api/users/me/default-config', defaultConfig);
const { data } = await request('GET', '/api/users/me');
const user = data as Record<string, unknown>;
const config = user.defaultGameConfig as Record<string, unknown>;
if (!config) throw new Error('defaultGameConfig not saved');
if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not saved');
if (config.streakThreshold !== 4) throw new Error('streakThreshold not saved');
if (config.comebackBonusPoints !== 75) throw new Error('comebackBonusPoints not saved');
});
await test('PUT /api/users/me/default-config with null clears config', async () => {
await request('PUT', '/api/users/me/default-config', { defaultGameConfig: null });
const { data } = await request('GET', '/api/users/me');
const user = data as Record<string, unknown>;
if (user.defaultGameConfig !== null) throw new Error('defaultGameConfig should be null after clearing');
});
await test('PUT /api/users/me/default-config with partial config saves as-is', async () => {
const partialConfig = {
defaultGameConfig: {
shuffleQuestions: true,
hostParticipates: false,
},
};
await request('PUT', '/api/users/me/default-config', partialConfig);
const { data } = await request('GET', '/api/users/me');
const user = data as Record<string, unknown>;
const config = user.defaultGameConfig as Record<string, unknown>;
if (!config) throw new Error('Partial config not saved');
if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not in partial config');
if (config.hostParticipates !== false) throw new Error('hostParticipates not in partial config');
});
await test('PUT /api/users/me/default-config cleanup - reset to null', async () => {
await request('PUT', '/api/users/me/default-config', { defaultGameConfig: null });
});
console.log('\nPhase 6 - Duplicate/Idempotency Tests:');
await test('POST /api/quizzes with same data creates separate quizzes', async () => {

View file

@ -61,23 +61,12 @@ ${baseInstructions}`;
${baseInstructions}`;
}
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
function transformToQuiz(data: any): Quiz {
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
const questions: Question[] = data.questions.map((q: any) => {
const shuffledOpts = shuffleArray(q.options);
const options: AnswerOption[] = shuffledOpts.map((opt: any, index: number) => ({
const options: AnswerOption[] = q.options.map((opt: any, index: number) => ({
text: opt.text,
isCorrect: opt.isCorrect,
shape: shapes[index % 4],

View file

@ -1,330 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DefaultConfigModal } from '../../components/DefaultConfigModal';
import { DEFAULT_GAME_CONFIG } from '../../types';
import type { GameConfig } from '../../types';
describe('DefaultConfigModal', () => {
const mockOnClose = vi.fn();
const mockOnChange = vi.fn();
const mockOnSave = vi.fn();
const defaultProps = {
isOpen: true,
onClose: mockOnClose,
config: { ...DEFAULT_GAME_CONFIG },
onChange: mockOnChange,
onSave: mockOnSave,
saving: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders nothing when isOpen is false', () => {
render(<DefaultConfigModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Default Game Settings')).not.toBeInTheDocument();
});
it('renders modal when isOpen is true', () => {
render(<DefaultConfigModal {...defaultProps} />);
expect(screen.getByText('Default Game Settings')).toBeInTheDocument();
});
it('displays subtitle explaining the settings', () => {
render(<DefaultConfigModal {...defaultProps} />);
expect(screen.getByText('Applied to all new quizzes')).toBeInTheDocument();
});
it('renders GameConfigPanel with config', () => {
render(<DefaultConfigModal {...defaultProps} />);
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
expect(screen.getByText('Host Participates')).toBeInTheDocument();
});
it('renders Cancel button', () => {
render(<DefaultConfigModal {...defaultProps} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
it('renders Save Defaults button', () => {
render(<DefaultConfigModal {...defaultProps} />);
expect(screen.getByText('Save Defaults')).toBeInTheDocument();
});
it('renders close X button', () => {
render(<DefaultConfigModal {...defaultProps} />);
const closeButtons = screen.getAllByRole('button');
const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
expect(xButton).toBeInTheDocument();
});
});
describe('interactions - happy path', () => {
it('calls onClose when Cancel is clicked', async () => {
const user = userEvent.setup();
render(<DefaultConfigModal {...defaultProps} />);
await user.click(screen.getByText('Cancel'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when X button is clicked', async () => {
const user = userEvent.setup();
render(<DefaultConfigModal {...defaultProps} />);
const closeButtons = screen.getAllByRole('button');
const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
await user.click(xButton!);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when backdrop is clicked', () => {
render(<DefaultConfigModal {...defaultProps} />);
const backdrop = document.querySelector('.fixed.inset-0');
fireEvent.click(backdrop!);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('does not close when modal content is clicked', async () => {
const user = userEvent.setup();
render(<DefaultConfigModal {...defaultProps} />);
await user.click(screen.getByText('Default Game Settings'));
expect(mockOnClose).not.toHaveBeenCalled();
});
it('calls onSave when Save Defaults is clicked', async () => {
const user = userEvent.setup();
render(<DefaultConfigModal {...defaultProps} />);
await user.click(screen.getByText('Save Defaults'));
expect(mockOnSave).toHaveBeenCalledTimes(1);
});
it('calls onChange when config is modified', async () => {
const user = userEvent.setup();
render(<DefaultConfigModal {...defaultProps} />);
await user.click(screen.getByText('Shuffle Questions'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
});
});
});
describe('interactions - unhappy path / edge cases', () => {
it('disables Save button when saving is true', () => {
render(<DefaultConfigModal {...defaultProps} saving={true} />);
const saveButton = screen.getByText('Saving...').closest('button');
expect(saveButton).toBeDisabled();
});
it('shows Saving... text when saving', () => {
render(<DefaultConfigModal {...defaultProps} saving={true} />);
expect(screen.getByText('Saving...')).toBeInTheDocument();
expect(screen.queryByText('Save Defaults')).not.toBeInTheDocument();
});
it('does not call onSave when button is disabled and clicked', async () => {
const user = userEvent.setup();
render(<DefaultConfigModal {...defaultProps} saving={true} />);
const saveButton = screen.getByText('Saving...').closest('button')!;
await user.click(saveButton);
expect(mockOnSave).not.toHaveBeenCalled();
});
it('Cancel button is still clickable when saving', async () => {
const user = userEvent.setup();
render(<DefaultConfigModal {...defaultProps} saving={true} />);
await user.click(screen.getByText('Cancel'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('handles rapid clicks on Save button', async () => {
const user = userEvent.setup();
render(<DefaultConfigModal {...defaultProps} />);
const saveButton = screen.getByText('Save Defaults');
await user.click(saveButton);
await user.click(saveButton);
await user.click(saveButton);
expect(mockOnSave).toHaveBeenCalledTimes(3);
});
it('remains functional after re-opening', async () => {
const user = userEvent.setup();
const { rerender } = render(<DefaultConfigModal {...defaultProps} />);
await user.click(screen.getByText('Save Defaults'));
expect(mockOnSave).toHaveBeenCalledTimes(1);
rerender(<DefaultConfigModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Default Game Settings')).not.toBeInTheDocument();
rerender(<DefaultConfigModal {...defaultProps} isOpen={true} />);
await user.click(screen.getByText('Save Defaults'));
expect(mockOnSave).toHaveBeenCalledTimes(2);
});
});
describe('config propagation', () => {
const getCheckboxForRow = (labelText: string) => {
const label = screen.getByText(labelText);
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
};
it('displays provided config values', () => {
const customConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
streakBonusEnabled: true,
streakThreshold: 5,
};
render(<DefaultConfigModal {...defaultProps} config={customConfig} />);
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true);
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
});
it('updates display when config prop changes', () => {
const { rerender } = render(<DefaultConfigModal {...defaultProps} />);
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(false);
const newConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true };
rerender(<DefaultConfigModal {...defaultProps} config={newConfig} />);
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true);
});
});
describe('state transitions', () => {
it('transitions from not saving to saving correctly', () => {
const { rerender } = render(<DefaultConfigModal {...defaultProps} saving={false} />);
expect(screen.getByText('Save Defaults')).toBeInTheDocument();
rerender(<DefaultConfigModal {...defaultProps} saving={true} />);
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
it('transitions from saving back to not saving', () => {
const { rerender } = render(<DefaultConfigModal {...defaultProps} saving={true} />);
expect(screen.getByText('Saving...')).toBeInTheDocument();
rerender(<DefaultConfigModal {...defaultProps} saving={false} />);
expect(screen.getByText('Save Defaults')).toBeInTheDocument();
});
it('transitions from open to closed preserves state for re-open', () => {
const customConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true };
const { rerender } = render(<DefaultConfigModal {...defaultProps} config={customConfig} />);
const getCheckbox = () => {
const label = screen.getByText('Shuffle Questions');
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
};
expect(getCheckbox().checked).toBe(true);
rerender(<DefaultConfigModal {...defaultProps} config={customConfig} isOpen={false} />);
rerender(<DefaultConfigModal {...defaultProps} config={customConfig} isOpen={true} />);
expect(getCheckbox().checked).toBe(true);
});
});
describe('accessibility', () => {
it('all interactive elements are focusable', () => {
render(<DefaultConfigModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
buttons.forEach(button => {
expect(button).not.toHaveAttribute('tabindex', '-1');
});
});
it('modal traps focus within when open', () => {
render(<DefaultConfigModal {...defaultProps} />);
const modal = document.querySelector('[class*="bg-white rounded-2xl"]');
expect(modal).toBeInTheDocument();
});
});
describe('with all config options enabled', () => {
it('renders all nested settings when all options are enabled', () => {
const allEnabledConfig: GameConfig = {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: true,
streakBonusEnabled: true,
streakThreshold: 3,
streakMultiplier: 1.2,
comebackBonusEnabled: true,
comebackBonusPoints: 100,
penaltyForWrongAnswer: true,
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
};
render(<DefaultConfigModal {...defaultProps} config={allEnabledConfig} />);
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
expect(screen.getByText('Multiplier')).toBeInTheDocument();
expect(screen.getAllByText('Bonus points').length).toBe(2);
expect(screen.getByText('Penalty')).toBeInTheDocument();
});
});
describe('async save behavior', () => {
it('handles async onSave that resolves', async () => {
const user = userEvent.setup();
const asyncOnSave = vi.fn().mockResolvedValue(undefined);
render(<DefaultConfigModal {...defaultProps} onSave={asyncOnSave} />);
await user.click(screen.getByText('Save Defaults'));
expect(asyncOnSave).toHaveBeenCalledTimes(1);
});
it('handles async onSave that rejects', async () => {
const user = userEvent.setup();
const asyncOnSave = vi.fn().mockRejectedValue(new Error('Save failed'));
render(<DefaultConfigModal {...defaultProps} onSave={asyncOnSave} />);
await user.click(screen.getByText('Save Defaults'));
expect(asyncOnSave).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -1,449 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GameConfigPanel } from '../../components/GameConfigPanel';
import { DEFAULT_GAME_CONFIG } from '../../types';
import type { GameConfig } from '../../types';
describe('GameConfigPanel', () => {
const mockOnChange = vi.fn();
const defaultProps = {
config: { ...DEFAULT_GAME_CONFIG },
onChange: mockOnChange,
questionCount: 10,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders all config toggles', () => {
render(<GameConfigPanel {...defaultProps} />);
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
expect(screen.getByText('Shuffle Answers')).toBeInTheDocument();
expect(screen.getByText('Host Participates')).toBeInTheDocument();
expect(screen.getByText('Streak Bonus')).toBeInTheDocument();
expect(screen.getByText('Comeback Bonus')).toBeInTheDocument();
expect(screen.getByText('Wrong Answer Penalty')).toBeInTheDocument();
expect(screen.getByText('First Correct Bonus')).toBeInTheDocument();
});
it('renders descriptions for each toggle', () => {
render(<GameConfigPanel {...defaultProps} />);
expect(screen.getByText('Randomize question order when starting')).toBeInTheDocument();
expect(screen.getByText('Randomize answer positions for each question')).toBeInTheDocument();
expect(screen.getByText('Join as a player and answer questions')).toBeInTheDocument();
});
it('renders compact collapsed state when compact=true', () => {
render(<GameConfigPanel {...defaultProps} compact />);
expect(screen.getByText('Game Settings')).toBeInTheDocument();
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
});
it('expands when clicking collapsed compact view', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} compact />);
await user.click(screen.getByText('Game Settings'));
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
});
it('collapses when clicking expanded compact view header', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} compact />);
await user.click(screen.getByText('Game Settings'));
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
await user.click(screen.getByText('Game Settings'));
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
});
});
describe('toggle interactions - happy path', () => {
const getCheckboxForLabel = (labelText: string) => {
const label = screen.getByText(labelText);
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]')!;
};
it('calls onChange when toggling shuffleQuestions', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
await user.click(screen.getByText('Shuffle Questions'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
});
});
it('calls onChange when toggling shuffleAnswers', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
await user.click(screen.getByText('Shuffle Answers'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
shuffleAnswers: true,
});
});
it('calls onChange when toggling hostParticipates off', async () => {
const user = userEvent.setup();
const config = { ...DEFAULT_GAME_CONFIG, hostParticipates: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
await user.click(screen.getByText('Host Participates'));
expect(mockOnChange).toHaveBeenCalledWith({
...config,
hostParticipates: false,
});
});
it('calls onChange when enabling streakBonus', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
await user.click(screen.getByText('Streak Bonus'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
streakBonusEnabled: true,
});
});
it('calls onChange when enabling comebackBonus', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
await user.click(screen.getByText('Comeback Bonus'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
comebackBonusEnabled: true,
});
});
it('calls onChange when enabling penalty', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
await user.click(screen.getByText('Wrong Answer Penalty'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
penaltyForWrongAnswer: true,
});
});
it('calls onChange when enabling firstCorrectBonus', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
await user.click(screen.getByText('First Correct Bonus'));
expect(mockOnChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
firstCorrectBonusEnabled: true,
});
});
});
describe('nested number inputs - happy path', () => {
it('shows streak settings when streakBonusEnabled', () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
expect(screen.getByText('Multiplier')).toBeInTheDocument();
});
it('shows comeback settings when comebackBonusEnabled', () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
expect(screen.getByText('Bonus points')).toBeInTheDocument();
});
it('shows penalty settings when penaltyForWrongAnswer enabled', () => {
const config = { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
expect(screen.getByText('Penalty')).toBeInTheDocument();
});
it('updates streakThreshold value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement;
fireEvent.change(thresholdInput, { target: { value: '5' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ streakThreshold: 5 })
);
});
it('updates streakMultiplier value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
const multiplierInput = screen.getByDisplayValue('1.1') as HTMLInputElement;
fireEvent.change(multiplierInput, { target: { value: '1.5' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ streakMultiplier: 1.5 })
);
});
it('updates comebackBonusPoints value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement;
fireEvent.change(bonusInput, { target: { value: '100' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ comebackBonusPoints: 100 })
);
});
it('updates penaltyPercent value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
const penaltyInput = screen.getByDisplayValue('25') as HTMLInputElement;
fireEvent.change(penaltyInput, { target: { value: '50' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ penaltyPercent: 50 })
);
});
it('updates firstCorrectBonusPoints value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement;
fireEvent.change(bonusInput, { target: { value: '75' } });
expect(mockOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ firstCorrectBonusPoints: 75 })
);
});
});
describe('suggested values', () => {
it('displays suggested comeback bonus based on question count', () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} questionCount={10} />);
expect(screen.getByText(/Suggested for 10 questions: 100 pts/)).toBeInTheDocument();
});
it('displays suggested first correct bonus based on question count', () => {
const config = { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} questionCount={10} />);
expect(screen.getByText(/Suggested for 10 questions: 50 pts/)).toBeInTheDocument();
});
it('updates suggested values when question count changes', () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
const { rerender } = render(
<GameConfigPanel {...defaultProps} config={config} questionCount={10} />
);
expect(screen.getByText(/Suggested for 10 questions: 100 pts/)).toBeInTheDocument();
rerender(<GameConfigPanel {...defaultProps} config={config} questionCount={20} />);
expect(screen.getByText(/Suggested for 20 questions: 150 pts/)).toBeInTheDocument();
});
});
describe('interactions - unhappy path / edge cases', () => {
it('handles rapid toggle clicks', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
const label = screen.getByText('Shuffle Questions');
await user.click(label);
await user.click(label);
await user.click(label);
expect(mockOnChange).toHaveBeenCalledTimes(3);
});
it('handles number input with invalid value (converts to 0)', async () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement;
fireEvent.change(thresholdInput, { target: { value: 'abc' } });
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ streakThreshold: 0 })
);
});
it('handles number input with negative value', async () => {
const config = { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
const bonusInput = screen.getByDisplayValue('50') as HTMLInputElement;
fireEvent.change(bonusInput, { target: { value: '-10' } });
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ comebackBonusPoints: -10 })
);
});
it('handles empty number input (converts to 0)', async () => {
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
render(<GameConfigPanel {...defaultProps} config={config} />);
const thresholdInput = screen.getByDisplayValue('3') as HTMLInputElement;
fireEvent.change(thresholdInput, { target: { value: '' } });
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ streakThreshold: 0 })
);
});
it('does not show nested settings when toggle is off', () => {
render(<GameConfigPanel {...defaultProps} />);
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
expect(screen.queryByText('Multiplier')).not.toBeInTheDocument();
});
it('hides nested settings when toggle is turned off', async () => {
const user = userEvent.setup();
const config = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true };
const { rerender } = render(<GameConfigPanel {...defaultProps} config={config} />);
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
rerender(<GameConfigPanel {...defaultProps} config={{ ...config, streakBonusEnabled: false }} />);
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
});
});
describe('tooltip interactions', () => {
it('shows tooltip on hover', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
const infoButtons = screen.getAllByRole('button').filter(
btn => btn.querySelector('svg')
);
if (infoButtons.length > 0) {
await user.hover(infoButtons[0]);
}
});
it('shows tooltip on click', async () => {
const user = userEvent.setup();
render(<GameConfigPanel {...defaultProps} />);
const infoButtons = screen.getAllByRole('button').filter(
btn => btn.querySelector('svg')
);
if (infoButtons.length > 0) {
await user.click(infoButtons[0]);
}
});
});
describe('state management', () => {
const getCheckboxForRow = (labelText: string) => {
const label = screen.getByText(labelText);
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
};
it('reflects updated config values correctly', () => {
const customConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: false,
streakBonusEnabled: true,
streakThreshold: 5,
streakMultiplier: 1.5,
};
render(<GameConfigPanel {...defaultProps} config={customConfig} />);
expect(getCheckboxForRow('Shuffle Questions').checked).toBe(true);
expect(getCheckboxForRow('Shuffle Answers').checked).toBe(true);
expect(getCheckboxForRow('Host Participates').checked).toBe(false);
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
expect(screen.getByDisplayValue('1.5')).toBeInTheDocument();
});
it('handles config with all options enabled', () => {
const allEnabledConfig: GameConfig = {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: true,
streakBonusEnabled: true,
streakThreshold: 3,
streakMultiplier: 1.2,
comebackBonusEnabled: true,
comebackBonusPoints: 100,
penaltyForWrongAnswer: true,
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
};
render(<GameConfigPanel {...defaultProps} config={allEnabledConfig} />);
expect(screen.getByText('Streak threshold')).toBeInTheDocument();
expect(screen.getAllByText('Bonus points').length).toBe(2);
expect(screen.getByText('Penalty')).toBeInTheDocument();
});
it('handles config with all options disabled', () => {
const allDisabledConfig: GameConfig = {
shuffleQuestions: false,
shuffleAnswers: false,
hostParticipates: false,
streakBonusEnabled: false,
streakThreshold: 3,
streakMultiplier: 1.1,
comebackBonusEnabled: false,
comebackBonusPoints: 50,
penaltyForWrongAnswer: false,
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
};
render(<GameConfigPanel {...defaultProps} config={allDisabledConfig} />);
expect(screen.queryByText('Streak threshold')).not.toBeInTheDocument();
expect(screen.queryByText('Bonus points')).not.toBeInTheDocument();
expect(screen.queryByText('Penalty')).not.toBeInTheDocument();
});
});
});

View file

@ -1,422 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QuizEditor } from '../../components/QuizEditor';
import { DEFAULT_GAME_CONFIG } from '../../types';
import type { Quiz, GameConfig } from '../../types';
vi.mock('uuid', () => ({
v4: () => 'mock-uuid-' + Math.random().toString(36).substr(2, 9),
}));
const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
title: 'Test Quiz',
questions: [
{
id: 'q1',
text: 'What is 2+2?',
timeLimit: 20,
options: [
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
{ text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
{ text: '6', isCorrect: false, shape: 'square', color: 'green' },
],
},
],
...overrides,
});
describe('QuizEditor - Game Config Integration', () => {
const mockOnSave = vi.fn();
const mockOnStartGame = vi.fn();
const mockOnConfigChange = vi.fn();
const mockOnBack = vi.fn();
const defaultProps = {
quiz: createMockQuiz(),
onSave: mockOnSave,
onStartGame: mockOnStartGame,
onConfigChange: mockOnConfigChange,
onBack: mockOnBack,
showSaveButton: true,
isSaving: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('config initialization', () => {
it('uses DEFAULT_GAME_CONFIG when quiz has no config', () => {
render(<QuizEditor {...defaultProps} />);
expect(screen.getByText('Game Settings')).toBeInTheDocument();
});
it('uses quiz config when provided', async () => {
const user = userEvent.setup();
const quizWithConfig = createMockQuiz({
config: {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
streakBonusEnabled: true,
},
});
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} />);
await user.click(screen.getByText('Game Settings'));
const getCheckbox = (labelText: string) => {
const label = screen.getByText(labelText);
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
};
expect(getCheckbox('Shuffle Questions').checked).toBe(true);
});
it('uses defaultConfig prop when quiz has no config', async () => {
const user = userEvent.setup();
const customDefault: GameConfig = {
...DEFAULT_GAME_CONFIG,
hostParticipates: false,
penaltyForWrongAnswer: true,
};
render(<QuizEditor {...defaultProps} defaultConfig={customDefault} />);
await user.click(screen.getByText('Game Settings'));
const getCheckbox = (labelText: string) => {
const label = screen.getByText(labelText);
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
};
expect(getCheckbox('Host Participates').checked).toBe(false);
expect(getCheckbox('Wrong Answer Penalty').checked).toBe(true);
});
it('prioritizes quiz config over defaultConfig prop', async () => {
const user = userEvent.setup();
const quizWithConfig = createMockQuiz({
config: {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
},
});
const customDefault: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: false,
shuffleAnswers: true,
};
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} defaultConfig={customDefault} />);
await user.click(screen.getByText('Game Settings'));
const getCheckbox = (labelText: string) => {
const label = screen.getByText(labelText);
const row = label.closest('[class*="bg-white rounded-xl"]')!;
return row.querySelector('input[type="checkbox"]') as HTMLInputElement;
};
expect(getCheckbox('Shuffle Questions').checked).toBe(true);
expect(getCheckbox('Shuffle Answers').checked).toBe(false);
});
});
describe('config panel interactions', () => {
it('renders collapsed Game Settings by default (compact mode)', () => {
render(<QuizEditor {...defaultProps} />);
expect(screen.getByText('Game Settings')).toBeInTheDocument();
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
});
it('expands config panel when Game Settings is clicked', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
expect(screen.getByText('Host Participates')).toBeInTheDocument();
});
it('collapses config panel when Game Settings is clicked again', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
expect(screen.getByText('Shuffle Questions')).toBeInTheDocument();
await user.click(screen.getByText('Game Settings'));
expect(screen.queryByText('Shuffle Questions')).not.toBeInTheDocument();
});
it('calls onConfigChange when config is modified', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
await user.click(screen.getByText('Shuffle Questions'));
expect(mockOnConfigChange).toHaveBeenCalledWith({
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
});
});
it('calls onConfigChange multiple times for multiple changes', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
await user.click(screen.getByText('Shuffle Questions'));
await user.click(screen.getByText('Shuffle Answers'));
expect(mockOnConfigChange).toHaveBeenCalledTimes(2);
});
});
describe('start game with config', () => {
it('passes config to onStartGame', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Test Quiz',
config: DEFAULT_GAME_CONFIG,
}),
DEFAULT_GAME_CONFIG
);
});
it('passes modified config to onStartGame', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
await user.click(screen.getByText('Shuffle Questions'));
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
}),
}),
expect.objectContaining({
shuffleQuestions: true,
})
);
});
it('shuffles questions when shuffleQuestions is enabled', async () => {
const user = userEvent.setup();
const multiQuestionQuiz = createMockQuiz({
questions: [
{ id: 'q1', text: 'Q1', timeLimit: 20, options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }] },
{ id: 'q2', text: 'Q2', timeLimit: 20, options: [{ text: 'C', isCorrect: true, shape: 'circle', color: 'yellow' }, { text: 'D', isCorrect: false, shape: 'square', color: 'green' }] },
{ id: 'q3', text: 'Q3', timeLimit: 20, options: [{ text: 'E', isCorrect: true, shape: 'triangle', color: 'red' }, { text: 'F', isCorrect: false, shape: 'diamond', color: 'blue' }] },
],
config: { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true },
});
render(<QuizEditor {...defaultProps} quiz={multiQuestionQuiz} />);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
questions: expect.any(Array),
}),
expect.objectContaining({
shuffleQuestions: true,
})
);
const calledQuiz = mockOnStartGame.mock.calls[0][0];
expect(calledQuiz.questions).toHaveLength(3);
});
it('shuffles answers when shuffleAnswers is enabled', async () => {
const user = userEvent.setup();
const quizWithConfig = createMockQuiz({
config: { ...DEFAULT_GAME_CONFIG, shuffleAnswers: true },
});
render(<QuizEditor {...defaultProps} quiz={quizWithConfig} />);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
questions: expect.arrayContaining([
expect.objectContaining({
options: expect.any(Array),
}),
]),
}),
expect.objectContaining({
shuffleAnswers: true,
})
);
});
});
describe('config persistence', () => {
it('maintains config state across quiz title edits', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
await user.click(screen.getByText('Shuffle Questions'));
await user.click(screen.getByText('Test Quiz'));
const titleInput = screen.getByDisplayValue('Test Quiz');
await user.clear(titleInput);
await user.type(titleInput, 'New Title');
fireEvent.blur(titleInput);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
}),
}),
expect.any(Object)
);
});
it('maintains config state across question additions', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
await user.click(screen.getByText('Host Participates'));
await user.click(screen.getByText('Add Question'));
expect(mockOnConfigChange).toHaveBeenCalledWith(
expect.objectContaining({
hostParticipates: false,
})
);
});
});
describe('edge cases', () => {
it('handles quiz with all config options enabled', async () => {
const user = userEvent.setup();
const fullConfigQuiz = createMockQuiz({
config: {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: true,
streakBonusEnabled: true,
streakThreshold: 5,
streakMultiplier: 1.5,
comebackBonusEnabled: true,
comebackBonusPoints: 100,
penaltyForWrongAnswer: true,
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
},
});
render(<QuizEditor {...defaultProps} quiz={fullConfigQuiz} />);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
streakBonusEnabled: true,
comebackBonusEnabled: true,
penaltyForWrongAnswer: true,
firstCorrectBonusEnabled: true,
}),
}),
expect.any(Object)
);
});
it('handles config without onConfigChange callback', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} onConfigChange={undefined} />);
await user.click(screen.getByText('Game Settings'));
await user.click(screen.getByText('Shuffle Questions'));
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
}),
}),
expect.any(Object)
);
});
it('handles empty questions array', () => {
const emptyQuiz = createMockQuiz({ questions: [] });
render(<QuizEditor {...defaultProps} quiz={emptyQuiz} />);
const startButton = screen.getByText(/Start Game/).closest('button');
expect(startButton).toBeDisabled();
});
it('handles quiz without title', () => {
const noTitleQuiz = createMockQuiz({ title: '' });
render(<QuizEditor {...defaultProps} quiz={noTitleQuiz} />);
expect(screen.getByText('Untitled Quiz')).toBeInTheDocument();
});
});
describe('onConfigChange callback timing', () => {
it('calls onConfigChange immediately when toggle changes', async () => {
const user = userEvent.setup();
render(<QuizEditor {...defaultProps} />);
await user.click(screen.getByText('Game Settings'));
await user.click(screen.getByText('Shuffle Questions'));
expect(mockOnConfigChange).toHaveBeenCalledTimes(1);
});
it('calls onConfigChange for nested number input changes', async () => {
const user = userEvent.setup();
const quizWithStreak = createMockQuiz({
config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true },
});
render(<QuizEditor {...defaultProps} quiz={quizWithStreak} />);
await user.click(screen.getByText('Game Settings'));
const thresholdInput = screen.getByDisplayValue('3');
await user.clear(thresholdInput);
await user.type(thresholdInput, '5');
expect(mockOnConfigChange).toHaveBeenCalled();
});
});
});

View file

@ -1,503 +0,0 @@
import React from 'react';
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useUserConfig } from '../../hooks/useUserConfig';
import { DEFAULT_GAME_CONFIG } from '../../types';
import type { GameConfig } from '../../types';
const mockAuthFetch = vi.fn();
const mockIsAuthenticated = vi.fn(() => true);
vi.mock('../../hooks/useAuthenticatedFetch', () => ({
useAuthenticatedFetch: () => ({
authFetch: mockAuthFetch,
isAuthenticated: mockIsAuthenticated(),
}),
}));
vi.mock('react-hot-toast', () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe('useUserConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsAuthenticated.mockReturnValue(true);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('initialization', () => {
it('returns DEFAULT_GAME_CONFIG initially', () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
});
const { result } = renderHook(() => useUserConfig());
expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG);
});
it('fetches default config on mount when authenticated', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
});
renderHook(() => useUserConfig());
await waitFor(() => {
expect(mockAuthFetch).toHaveBeenCalledWith('/api/users/me');
});
});
it('does not fetch when not authenticated', () => {
mockIsAuthenticated.mockReturnValue(false);
renderHook(() => useUserConfig());
expect(mockAuthFetch).not.toHaveBeenCalled();
});
});
describe('fetchDefaultConfig - happy path', () => {
it('updates defaultConfig when server returns config', async () => {
const serverConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
streakBonusEnabled: true,
};
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: serverConfig }),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.defaultConfig).toEqual(serverConfig);
});
});
it('sets loading to true during fetch', async () => {
let resolvePromise: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockAuthFetch.mockReturnValueOnce(pendingPromise);
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(true);
});
await act(async () => {
resolvePromise!({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
});
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
});
it('keeps DEFAULT_GAME_CONFIG when server returns null config', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG);
});
});
describe('fetchDefaultConfig - unhappy path', () => {
it('handles non-ok response gracefully', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG);
});
it('handles network error gracefully', async () => {
mockAuthFetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG);
});
it('handles 401 unauthorized gracefully', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: false,
status: 401,
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG);
});
});
describe('saveDefaultConfig - happy path', () => {
it('successfully saves config to server', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const newConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
hostParticipates: false,
};
await act(async () => {
await result.current.saveDefaultConfig(newConfig);
});
expect(mockAuthFetch).toHaveBeenLastCalledWith('/api/users/me/default-config', {
method: 'PUT',
body: JSON.stringify({ defaultGameConfig: newConfig }),
});
});
it('updates local state after successful save', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const newConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleAnswers: true,
};
await act(async () => {
await result.current.saveDefaultConfig(newConfig);
});
expect(result.current.defaultConfig).toEqual(newConfig);
});
it('sets saving to true during save', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let resolvePromise: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockAuthFetch.mockReturnValueOnce(pendingPromise);
act(() => {
result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG });
});
await waitFor(() => {
expect(result.current.saving).toBe(true);
});
await act(async () => {
resolvePromise!({
ok: true,
json: () => Promise.resolve({ success: true }),
});
});
await waitFor(() => {
expect(result.current.saving).toBe(false);
});
});
});
describe('saveDefaultConfig - unhappy path', () => {
it('throws error when save fails with non-ok response', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await expect(
act(async () => {
await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG });
})
).rejects.toThrow('Failed to save defaults');
});
it('throws error when network fails', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
})
.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await expect(
act(async () => {
await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG });
})
).rejects.toThrow('Network error');
});
it('resets saving state on error', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
})
.mockRejectedValueOnce(new Error('Server error'));
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
try {
await act(async () => {
await result.current.saveDefaultConfig({ ...DEFAULT_GAME_CONFIG });
});
} catch {
// Expected to throw
}
expect(result.current.saving).toBe(false);
});
it('does not update local state on save failure', async () => {
const initialConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
};
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: initialConfig }),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.defaultConfig).toEqual(initialConfig);
});
const newConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleAnswers: true,
};
try {
await act(async () => {
await result.current.saveDefaultConfig(newConfig);
});
} catch {
// Expected to throw
}
expect(result.current.defaultConfig).toEqual(initialConfig);
});
});
describe('concurrent operations', () => {
it('handles multiple save calls correctly', async () => {
mockAuthFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: null }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const config1: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true };
const config2: GameConfig = { ...DEFAULT_GAME_CONFIG, shuffleAnswers: true };
await act(async () => {
await result.current.saveDefaultConfig(config1);
});
await act(async () => {
await result.current.saveDefaultConfig(config2);
});
expect(result.current.defaultConfig).toEqual(config2);
});
});
describe('edge cases', () => {
it('handles partial config from server', async () => {
const partialConfig = {
shuffleQuestions: true,
};
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: partialConfig }),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.defaultConfig).toEqual(partialConfig);
});
it('handles empty response body', async () => {
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.defaultConfig).toEqual(DEFAULT_GAME_CONFIG);
});
it('handles config with extra fields from server', async () => {
const serverConfigWithExtras = {
...DEFAULT_GAME_CONFIG,
unknownField: 'value',
anotherUnknown: 123,
};
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: serverConfigWithExtras }),
});
const { result } = renderHook(() => useUserConfig());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.defaultConfig).toEqual(serverConfigWithExtras);
});
});
describe('re-authentication scenarios', () => {
it('re-fetches config when authentication status changes', async () => {
mockIsAuthenticated.mockReturnValue(false);
const { result, rerender } = renderHook(() => useUserConfig());
expect(mockAuthFetch).not.toHaveBeenCalled();
mockIsAuthenticated.mockReturnValue(true);
mockAuthFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ defaultGameConfig: { ...DEFAULT_GAME_CONFIG, shuffleQuestions: true } }),
});
rerender();
await waitFor(() => {
expect(mockAuthFetch).toHaveBeenCalledWith('/api/users/me');
});
});
});
});

View file

@ -27,40 +27,9 @@ export interface Question {
timeLimit: number; // in seconds
}
export interface GameConfig {
shuffleQuestions: boolean;
shuffleAnswers: boolean;
hostParticipates: boolean;
streakBonusEnabled: boolean;
streakThreshold: number;
streakMultiplier: number;
comebackBonusEnabled: boolean;
comebackBonusPoints: number;
penaltyForWrongAnswer: boolean;
penaltyPercent: number;
firstCorrectBonusEnabled: boolean;
firstCorrectBonusPoints: number;
}
export const DEFAULT_GAME_CONFIG: GameConfig = {
shuffleQuestions: false,
shuffleAnswers: false,
hostParticipates: true,
streakBonusEnabled: false,
streakThreshold: 3,
streakMultiplier: 1.1,
comebackBonusEnabled: false,
comebackBonusPoints: 50,
penaltyForWrongAnswer: false,
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
};
export interface Quiz {
title: string;
questions: Question[];
config?: GameConfig;
}
export type QuizSource = 'manual' | 'ai_generated';
@ -71,7 +40,6 @@ export interface SavedQuiz extends Quiz {
aiTopic?: string;
createdAt: string;
updatedAt: string;
config?: GameConfig;
}
export interface QuizListItem {
@ -96,23 +64,12 @@ export interface GenerateQuizOptions {
documents?: ProcessedDocument[];
}
export interface PointsBreakdown {
basePoints: number;
streakBonus: number;
comebackBonus: number;
firstCorrectBonus: number;
penalty: number;
total: number;
}
export interface Player {
id: string;
name: string;
score: number;
previousScore: number;
streak: number;
lastAnswerCorrect: boolean | null;
pointsBreakdown: PointsBreakdown | null;
isBot: boolean;
avatarSeed: number;
color: string;
@ -137,7 +94,7 @@ export type NetworkMessage =
}
}
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } }
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number; breakdown: PointsBreakdown } }
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number } }
| { type: 'TIME_SYNC'; payload: { timeLeft: number } }
| { type: 'TIME_UP'; payload: {} }
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }