Compare commits

...

2 commits

Author SHA1 Message Date
fc270d437f
Redesign scoreboard 2026-01-14 01:55:49 -07:00
af21f2bcdc
feat: add comprehensive game configuration system
Add a centralized game configuration system that allows customizable
scoring mechanics and game rules. Users can now set default game
configurations that persist across sessions, and individual quizzes
can have their own configuration overrides.

## New Features

### Game Configuration Options
- Shuffle Questions: Randomize question order when starting a game
- Shuffle Answers: Randomize answer positions for each question
- Host Participates: Toggle whether the host plays as a competitor
  or spectates (host now shows as 'Spectator' when not participating)
- Streak Bonus: Multiplied points for consecutive correct answers,
  with configurable threshold and multiplier values
- Comeback Bonus: Extra points for players ranked below top 3
- Wrong Answer Penalty: Deduct percentage of max points for incorrect
  answers (configurable percentage)
- First Correct Bonus: Extra points for the first player to answer
  correctly on each question

### Default Settings Management
- New Settings icon in landing page header (authenticated users only)
- DefaultConfigModal for editing user-wide default game settings
- Default configs are loaded when creating new quizzes
- Defaults persist to database via new user API endpoints

### Reusable UI Components
- GameConfigPanel: Comprehensive toggle-based settings panel with
  expandable sub-options, tooltips, and suggested values based on
  question count
- DefaultConfigModal: Modal wrapper for editing default configurations

## Technical Changes

### Frontend
- New useUserConfig hook for fetching/saving user default configurations
- QuizEditor now uses GameConfigPanel instead of inline toggle checkboxes
- GameScreen handles spectator mode with disabled answer buttons
- Updated useGame hook with new scoring calculations and config state
- Improved useAuthenticatedFetch with deduped silent refresh and
  redirect-once pattern to prevent multiple auth redirects

### Backend
- Added game_config column to quizzes table (JSON storage)
- Added default_game_config column to users table
- New PATCH endpoint for quiz config updates: /api/quizzes/:id/config
- New PUT endpoint for user defaults: /api/users/me/default-config
- Auto-migration in connection.ts for existing databases

### Scoring System
- New calculatePoints() function in constants.ts handles all scoring
  logic including streaks, comebacks, penalties, and first-correct bonus
- New calculateBasePoints() for time-based point calculation
- New getPlayerRank() helper for comeback bonus eligibility

### Tests
- Added tests for DefaultConfigModal component
- Added tests for GameConfigPanel component
- Added tests for QuizEditor config integration
- Added tests for useUserConfig hook
- Updated API tests for new endpoints

## Type Changes
- Added GameConfig interface with all configuration options
- Added DEFAULT_GAME_CONFIG constant with sensible defaults
- Quiz type now includes optional config property
2026-01-14 01:43:23 -07:00
24 changed files with 3137 additions and 204 deletions

11
App.tsx
View file

@ -2,6 +2,7 @@ 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';
@ -12,7 +13,7 @@ import { RevealScreen } from './components/RevealScreen';
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
import { QuizEditor } from './components/QuizEditor';
import { SaveOptionsModal } from './components/SaveOptionsModal';
import type { Quiz } from './types';
import type { Quiz, GameConfig } from './types';
const seededRandom = (seed: number) => {
const x = Math.sin(seed * 9999) * 10000;
@ -42,6 +43,7 @@ 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 {
@ -74,7 +76,8 @@ function App() {
sourceQuizId,
updateQuizFromEditor,
startGameFromEditor,
backFromEditor
backFromEditor,
gameConfig
} = useGame();
const handleSaveQuiz = async () => {
@ -152,7 +155,8 @@ function App() {
onSave={handleEditorSave}
onStartGame={startGameFromEditor}
onBack={backFromEditor}
sourceQuizId={sourceQuizId}
showSaveButton={auth.isAuthenticated}
defaultConfig={defaultConfig}
/>
) : null}
@ -195,6 +199,7 @@ function App() {
onAnswer={handleAnswer}
hasAnswered={hasAnswered}
lastPointsEarned={lastPointsEarned}
hostPlays={gameConfig.hostParticipates}
/>
)
) : null}

View file

@ -0,0 +1,95 @@
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

@ -0,0 +1,325 @@
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,6 +13,7 @@ interface GameScreenProps {
onAnswer: (isCorrect: boolean) => void;
hasAnswered: boolean;
lastPointsEarned: number | null;
hostPlays?: boolean;
}
export const GameScreen: React.FC<GameScreenProps> = ({
@ -24,8 +25,10 @@ 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);
@ -51,7 +54,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 ? 'Controller' : 'Host'}
{isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
</div>
</div>
@ -80,9 +83,11 @@ export const GameScreen: React.FC<GameScreenProps> = ({
let opacityClass = "opacity-100";
let scaleClass = "scale-100";
let cursorClass = "";
// If answering phase and user answered, dim everything
if (hasAnswered) {
if (isSpectator) {
cursorClass = "cursor-default";
} else if (hasAnswered) {
opacityClass = "opacity-50 cursor-not-allowed grayscale";
scaleClass = "scale-95";
}
@ -93,14 +98,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}
onClick={() => onAnswer(option as any)}
disabled={hasAnswered || isSpectator}
onClick={() => !isSpectator && onAnswer(option as any)}
className={`
${colorClass} ${opacityClass} ${scaleClass}
${colorClass} ${opacityClass} ${scaleClass} ${cursorClass}
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
active:shadow-none active:translate-y-[8px] active:scale-95
${!isSpectator ? '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,11 +1,13 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } from 'lucide-react';
import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } 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 type { Quiz } from '../types';
import { useUserConfig } from '../hooks/useUserConfig';
import type { Quiz, GameConfig } from '../types';
type GenerateMode = 'topic' | 'document';
@ -31,11 +33,15 @@ 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,
loading: libraryLoading,
@ -132,7 +138,19 @@ 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">
<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>
)}
<AuthButton />
</div>
<motion.div
@ -416,6 +434,24 @@ 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,41 +1,52 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } from 'lucide-react';
import { ArrowLeft, Save, Plus, Play, AlertTriangle } 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 } from '../types';
import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } 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) => void;
onStartGame: (quiz: Quiz, config: GameConfig) => void;
onConfigChange?: (config: GameConfig) => void;
onBack: () => void;
showSaveButton?: boolean;
isSaving?: boolean;
defaultConfig?: GameConfig;
}
export const QuizEditor: React.FC<QuizEditorProps> = ({
quiz: initialQuiz,
onSave,
onStartGame,
onConfigChange,
onBack,
showSaveButton = true,
isSaving
isSaving,
defaultConfig,
}) => {
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 [shuffleQuestions, setShuffleQuestions] = useState(false);
const [shuffleAnswers, setShuffleAnswers] = useState(false);
const [config, setConfig] = useState<GameConfig>(
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
);
useBodyScrollLock(!!showDeleteConfirm);
const handleConfigChange = useCallback((newConfig: GameConfig) => {
setConfig(newConfig);
onConfigChange?.(newConfig);
}, [onConfigChange]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }
@ -117,11 +128,11 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
const handleStartGame = () => {
let questions = [...quiz.questions];
if (shuffleQuestions) {
if (config.shuffleQuestions) {
questions = questions.sort(() => Math.random() - 0.5);
}
if (shuffleAnswers) {
if (config.shuffleAnswers) {
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
@ -138,7 +149,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
});
}
onStartGame({ ...quiz, questions });
onStartGame({ ...quiz, questions, config }, config);
};
return (
@ -242,51 +253,12 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
</div>
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 space-y-4">
<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"
<GameConfigPanel
config={config}
onChange={handleConfigChange}
questionCount={quiz.questions.length}
compact
/>
<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,17 +1,11 @@
import React, { useState, useEffect } from '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 { Player, PointsBreakdown } from '../types';
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react';
import { PlayerAvatar } from './PlayerAvatar';
const AnimatedScoreLabel: React.FC<{
x: number;
y: number;
width: number;
value: number;
}> = ({ x, y, width, value }) => {
const spring = useSpring(0, { duration: 500 });
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => {
const spring = useSpring(0, { duration });
const display = useTransform(spring, (latest) => Math.round(latest));
const [displayValue, setDisplayValue] = useState(0);
@ -21,18 +15,169 @@ const AnimatedScoreLabel: React.FC<{
return () => unsubscribe();
}, [value, spring, display]);
return (
<text
x={x + width + 15}
y={y}
dy={35}
fill="black"
fontSize={24}
fontWeight={900}
fontFamily="Fredoka"
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}`}
>
{displayValue}
</text>
{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"
>
<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>
);
};
@ -49,59 +194,23 @@ 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-12">
<header className="text-center mb-8">
<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-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 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>
<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-6xl w-full mx-auto">
<div className="mt-8 flex justify-end max-w-5xl w-full mx-auto">
{isHost ? (
<button
onClick={onNext}

View file

@ -1,4 +1,5 @@
import { Triangle, Diamond, Circle, Square } from 'lucide-react';
import type { GameConfig, Player, PointsBreakdown } from './types';
export const COLORS = {
red: 'bg-red-600',
@ -25,6 +26,79 @@ 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,10 +1,13 @@
import { useAuth } from 'react-oidc-context';
import { useCallback } from 'react';
import { useCallback, useRef } 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;
@ -14,28 +17,58 @@ 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()) {
try {
const user = await auth.signinSilent();
if (user?.access_token) {
return user.access_token;
const newToken = await attemptSilentRefresh();
if (newToken) {
return newToken;
}
} catch {
auth.signinRedirect();
redirectToLogin();
throw new Error('Session expired, redirecting to login');
}
}
return auth.user.access_token;
}, [auth, isTokenExpired]);
}, [auth.user?.access_token, isTokenExpired, attemptSilentRefresh, redirectToLogin]);
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.');
}
@ -61,21 +94,18 @@ export const useAuthenticatedFetch = () => {
}
if (response.status === 401) {
try {
const user = await auth.signinSilent();
if (user?.access_token) {
const newToken = await attemptSilentRefresh();
if (newToken) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${user.access_token}`,
Authorization: `Bearer ${newToken}`,
'Content-Type': 'application/json',
},
});
}
} catch {
auth.signinRedirect();
}
redirectToLogin();
throw new Error('Session expired, redirecting to login');
}
@ -85,7 +115,7 @@ export const useAuthenticatedFetch = () => {
return response;
},
[auth, ensureValidToken]
[ensureValidToken, attemptSilentRefresh, redirectToLogin]
);
return {

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument } from '../types';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
import { generateQuiz } from '../services/geminiService';
import { POINTS_PER_QUESTION, QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS } from '../constants';
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
import { Peer, DataConnection } from 'peerjs';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
@ -25,6 +25,8 @@ 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);
@ -36,11 +38,13 @@ 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 + "";
@ -123,9 +127,10 @@ export const useGame = () => {
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
};
const startGameFromEditor = (finalQuiz: Quiz) => {
const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => {
setQuiz(finalQuiz);
initializeHostGame(finalQuiz);
setGameConfig(config);
initializeHostGame(finalQuiz, config.hostParticipates);
};
const backFromEditor = () => {
@ -139,7 +144,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) => {
const initializeHostGame = (newQuiz: Quiz, hostParticipates: boolean = true) => {
setQuiz(newQuiz);
const pin = generateGamePin();
setGamePin(pin);
@ -148,12 +153,15 @@ 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]
@ -161,6 +169,11 @@ export const useGame = () => {
setPlayers([hostPlayer]);
setCurrentPlayerId('host');
setCurrentPlayerName('Host');
} else {
setPlayers([]);
setCurrentPlayerId(null);
setCurrentPlayerName(null);
}
setGameState('LOBBY');
});
@ -186,8 +199,10 @@ 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]
@ -205,15 +220,31 @@ export const useGame = () => {
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0;
const newScore = currentPlayer.score + points;
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);
setPlayers(prev => prev.map(p => {
if (p.id !== playerId) return p;
return { ...p, score: newScore, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect };
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown };
}));
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } });
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } });
}
};
@ -256,7 +287,8 @@ export const useGame = () => {
setLastPointsEarned(null);
setSelectedOption(null);
setTimeLeft(QUESTION_TIME_MS);
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null })));
setFirstCorrectPlayerId(null);
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null })));
const currentQuiz = quizRef.current;
const currentIndex = currentQuestionIndexRef.current;
@ -421,24 +453,44 @@ 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 newScore = (hostPlayer?.score || 0) + points;
const newStreak = isCorrect ? (hostPlayer?.streak || 0) + 1 : 0;
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);
setCurrentPlayerScore(newScore);
setCurrentStreak(newStreak);
setPlayers(prev => prev.map(p => {
if (p.id !== 'host') return p;
return { ...p, score: newScore, streak: newStreak, lastAnswerCorrect: isCorrect };
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown };
}));
} else {
const option = arg as AnswerOption;
@ -462,7 +514,7 @@ export const useGame = () => {
}, []);
return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
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 } from '../types';
import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig } from '../types';
interface UseQuizLibraryReturn {
quizzes: QuizListItem[];
@ -14,6 +14,7 @@ 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;
@ -126,6 +127,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
title: quiz.title,
source,
aiTopic,
gameConfig: quiz.config,
questions: quiz.questions.map(q => ({
text: q.text,
timeLimit: q.timeLimit,
@ -170,6 +172,7 @@ 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,
@ -203,6 +206,25 @@ 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);
@ -253,6 +275,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
loadQuiz,
saveQuiz,
updateQuiz,
updateQuizConfig,
deleteQuiz,
retry,
clearError,

73
hooks/useUserConfig.ts Normal file
View file

@ -0,0 +1,73 @@
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,4 +16,24 @@ 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

@ -0,0 +1,8 @@
-- 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,7 +4,8 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
last_login DATETIME,
default_game_config TEXT
);
CREATE TABLE IF NOT EXISTS quizzes (
@ -13,6 +14,7 @@ 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,10 +7,26 @@ 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;
@ -44,7 +60,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, created_at as createdAt, updated_at as updatedAt
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, 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;
@ -78,8 +94,18 @@ 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,
});
});
@ -118,7 +144,7 @@ function validateQuizBody(body: QuizBody): string | null {
router.post('/', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, source, aiTopic, questions } = body;
const { title, source, aiTopic, gameConfig, questions } = body;
const validationError = validateQuizBody(body);
if (validationError) {
@ -138,8 +164,8 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
`);
const insertQuiz = db.prepare(`
INSERT INTO quizzes (id, user_id, title, source, ai_topic)
VALUES (?, ?, ?, ?, ?)
INSERT INTO quizzes (id, user_id, title, source, ai_topic, game_config)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertQuestion = db.prepare(`
@ -160,7 +186,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
req.user!.name || null
);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : null);
questions.forEach((q, qIdx) => {
const questionId = uuidv4();
@ -187,7 +213,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, questions } = body;
const { title, questions, gameConfig } = body;
const quizId = req.params.id;
if (!title?.trim()) {
@ -227,7 +253,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
}
const updateQuiz = db.prepare(`
UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
UPDATE quizzes SET title = ?, game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`);
const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`);
@ -243,7 +269,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
`);
const transaction = db.transaction(() => {
updateQuiz.run(title, quizId);
updateQuiz.run(title, gameConfig ? JSON.stringify(gameConfig) : null, quizId);
deleteQuestions.run(quizId);
questions.forEach((q, qIdx) => {
@ -269,6 +295,26 @@ 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, created_at as createdAt, last_login as lastLogin
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, created_at as createdAt, last_login as lastLogin
FROM users
WHERE id = ?
`).get(req.user!.sub) as Record<string, unknown> | undefined;
@ -19,6 +19,7 @@ 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,
@ -26,7 +27,41 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
return;
}
res.json({ ...user, isNew: false });
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 });
});
export default router;

View file

@ -1257,6 +1257,271 @@ 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,12 +61,23 @@ ${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 options: AnswerOption[] = q.options.map((opt: any, index: number) => ({
const shuffledOpts = shuffleArray(q.options);
const options: AnswerOption[] = shuffledOpts.map((opt: any, index: number) => ({
text: opt.text,
isCorrect: opt.isCorrect,
shape: shapes[index % 4],

View file

@ -0,0 +1,330 @@
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

@ -0,0 +1,449 @@
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

@ -0,0 +1,422 @@
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

@ -0,0 +1,503 @@
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,9 +27,40 @@ 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';
@ -40,6 +71,7 @@ export interface SavedQuiz extends Quiz {
aiTopic?: string;
createdAt: string;
updatedAt: string;
config?: GameConfig;
}
export interface QuizListItem {
@ -64,12 +96,23 @@ 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;
@ -94,7 +137,7 @@ export type NetworkMessage =
}
}
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } }
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number } }
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number; breakdown: PointsBreakdown } }
| { type: 'TIME_SYNC'; payload: { timeLeft: number } }
| { type: 'TIME_UP'; payload: {} }
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }