Phase 5 complete

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 16:38:25 -07:00
commit 93ea01525e
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
7 changed files with 433 additions and 37 deletions

41
App.tsx
View file

@ -1,5 +1,7 @@
import React from 'react';
import { useAuth } from 'react-oidc-context';
import { useGame } from './hooks/useGame';
import { useQuizLibrary } from './hooks/useQuizLibrary';
import { Landing } from './components/Landing';
import { Lobby } from './components/Lobby';
import { GameScreen } from './components/GameScreen';
@ -7,6 +9,7 @@ import { Scoreboard } from './components/Scoreboard';
import { Podium } from './components/Podium';
import { QuizCreator } from './components/QuizCreator';
import { RevealScreen } from './components/RevealScreen';
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
const seededRandom = (seed: number) => {
const x = Math.sin(seed * 9999) * 10000;
@ -34,6 +37,8 @@ const FloatingShapes = React.memo(() => {
});
function App() {
const auth = useAuth();
const { saveQuiz } = useQuizLibrary();
const {
role,
gameState,
@ -57,9 +62,19 @@ function App() {
selectedOption,
currentPlayerScore,
currentStreak,
currentPlayerId
currentPlayerId,
pendingQuizToSave,
dismissSavePrompt
} = useGame();
const handleSaveQuiz = async () => {
if (!pendingQuizToSave) return;
const source = pendingQuizToSave.topic ? 'ai_generated' : 'manual';
const topic = pendingQuizToSave.topic || undefined;
await saveQuiz(pendingQuizToSave.quiz, source, topic);
dismissSavePrompt();
};
const currentQ = quiz?.questions[currentQuestionIndex];
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
@ -92,13 +107,23 @@ function App() {
) : null}
{gameState === 'LOBBY' ? (
<Lobby
quizTitle={quiz?.title || 'Kaboot'}
players={players}
gamePin={gamePin}
role={role}
onStart={startGame}
/>
<>
<Lobby
quizTitle={quiz?.title || 'Kaboot'}
players={players}
gamePin={gamePin}
role={role}
onStart={startGame}
/>
{auth.isAuthenticated && pendingQuizToSave && (
<SaveQuizPrompt
isOpen={true}
quizTitle={pendingQuizToSave.quiz.title}
onSave={handleSaveQuiz}
onSkip={dismissSavePrompt}
/>
)}
</>
) : null}
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? (

View file

@ -236,31 +236,31 @@ Add user accounts via Authentik (OIDC) and persist quizzes to SQLite database. U
## Phase 5: Save Integration
### 5.1 Save After AI Generation
- [ ] Modify `src/hooks/useGame.ts`:
- [ ] Add `pendingQuizToSave` state
- [ ] After successful AI generation, set `pendingQuizToSave`
- [ ] Add `savePendingQuiz()` and `dismissSavePrompt()` functions
- [ ] Export these for UI to consume
- [ ] Create `src/components/SaveQuizPrompt.tsx`:
- [ ] Modal asking "Save this quiz to your library?"
- [ ] Show quiz title
- [ ] "Save" and "Skip" buttons
- [ ] Only show when authenticated
- [x] Modify `hooks/useGame.ts`:
- [x] Add `pendingQuizToSave` state
- [x] After successful AI generation, set `pendingQuizToSave`
- [x] Add `dismissSavePrompt()` function
- [x] Export these for UI to consume
- [x] Create `components/SaveQuizPrompt.tsx`:
- [x] Modal asking "Save this quiz to your library?"
- [x] Show quiz title
- [x] "Save" and "Skip" buttons
- [x] Loading state while saving
- [x] Wire up in `App.tsx`:
- [x] Show SaveQuizPrompt in LOBBY state when authenticated and pendingQuizToSave exists
- [x] Handle save via useQuizLibrary hook
### 5.2 Save in Quiz Creator
- [ ] Modify `src/components/QuizCreator.tsx`:
- [ ] Add checkbox or toggle: "Save to my library"
- [ ] Pass `shouldSave` flag to `onFinalize`
- [ ] Modify `src/hooks/useGame.ts`:
- [ ] Update `finalizeManualQuiz` to accept save preference
- [ ] If save requested + authenticated, call `saveQuiz`
- [x] Modify `components/QuizCreator.tsx`:
- [x] Add "Save to my library" checkbox (only shown when authenticated)
- [x] Pass `saveToLibrary` flag to `onFinalize`
- [x] Modify `hooks/useGame.ts`:
- [x] Update `finalizeManualQuiz` to accept save preference
- [x] If save requested, set `pendingQuizToSave`
### 5.3 Load Quiz Flow
- [ ] Modify `src/hooks/useGame.ts`:
- [ ] Add `loadSavedQuiz(quiz: Quiz)` function
- [ ] Initialize game state with loaded quiz
- [ ] Transition to LOBBY state
- [ ] Wire up from Landing → QuizLibrary → useGame
### 5.3 Load Quiz Flow (Already done in Phase 4)
- [x] `loadSavedQuiz(quiz: Quiz)` function in useGame.ts
- [x] Wire up from Landing → QuizLibrary → useGame
---
@ -407,6 +407,6 @@ kaboot/
| Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD |
| Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch |
| Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration |
| Phase 5 | Not Started | |
| Phase 5 | **COMPLETE** | SaveQuizPrompt modal, QuizCreator save checkbox, save integration |
| Phase 6 | Not Started | |
| Phase 7 | Not Started | |

View file

@ -1,21 +1,24 @@
import React, { useState } from 'react';
import { useAuth } from 'react-oidc-context';
import { Quiz, Question, AnswerOption } from '../types';
import { v4 as uuidv4 } from 'uuid';
import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react';
import { Plus, Save, Trash2, CheckCircle, Circle, X, BookOpen } from 'lucide-react';
import { COLORS, SHAPES } from '../constants';
interface QuizCreatorProps {
onFinalize: (quiz: Quiz) => void;
onFinalize: (quiz: Quiz, saveToLibrary: boolean) => void;
onCancel: () => void;
}
export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }) => {
const auth = useAuth();
const [title, setTitle] = useState('');
const [questions, setQuestions] = useState<Question[]>([]);
const [qText, setQText] = useState('');
const [options, setOptions] = useState<string[]>(['', '', '', '']);
const [reasons, setReasons] = useState<string[]>(['', '', '', '']);
const [correctIdx, setCorrectIdx] = useState<number>(0);
const [saveToLibrary, setSaveToLibrary] = useState(false);
const handleAddQuestion = () => {
if (!qText.trim() || options.some(o => !o.trim())) {
@ -53,7 +56,7 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
const handleFinalize = () => {
if (!title.trim() || questions.length === 0) return;
onFinalize({ title, questions });
onFinalize({ title, questions }, saveToLibrary);
};
return (
@ -176,7 +179,25 @@ export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }
</div>
</div>
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-end">
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-between items-center">
{auth.isAuthenticated ? (
<label className="flex items-center gap-3 cursor-pointer select-none group">
<input
type="checkbox"
checked={saveToLibrary}
onChange={(e) => setSaveToLibrary(e.target.checked)}
className="sr-only peer"
/>
<div className="w-6 h-6 border-2 border-gray-300 rounded-lg flex items-center justify-center peer-checked:bg-theme-primary peer-checked:border-theme-primary transition-all group-hover:border-gray-400">
{saveToLibrary && <CheckCircle size={16} className="text-white" />}
</div>
<span className="text-gray-600 font-bold flex items-center gap-2">
<BookOpen size={18} /> Save to my library
</span>
</label>
) : (
<div />
)}
<button
onClick={handleFinalize}
className="flex items-center gap-2 bg-green-500 text-white px-10 py-4 rounded-2xl text-xl font-black hover:bg-green-600 shadow-[0_6px_0_#15803d] active:shadow-none active:translate-y-[6px] transition-all"

View file

@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Save, X, Loader2, BrainCircuit } from 'lucide-react';
interface SaveQuizPromptProps {
isOpen: boolean;
quizTitle: string;
onSave: () => Promise<void>;
onSkip: () => void;
}
export const SaveQuizPrompt: React.FC<SaveQuizPromptProps> = ({
isOpen,
quizTitle,
onSave,
onSkip
}) => {
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave();
} finally {
setIsSaving(false);
}
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={!isSaving ? onSkip : undefined}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: "spring", bounce: 0.4 }}
onClick={(e) => e.stopPropagation()}
className="bg-white w-full max-w-md flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden"
>
<div className="p-8 text-center">
<div className="bg-theme-primary/10 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6">
<BrainCircuit size={40} className="text-theme-primary" />
</div>
<h2 className="text-3xl font-black text-gray-900 mb-2 tracking-tight">Save this Quiz?</h2>
<p className="text-gray-500 font-bold mb-6">
"{quizTitle}" generated successfully! Would you like to save it to your library?
</p>
<div className="space-y-3">
<button
onClick={handleSave}
disabled={isSaving}
className="w-full bg-theme-primary text-white py-4 rounded-2xl text-xl font-black shadow-[0_4px_0_#1e40af] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2 hover:bg-theme-primary-dark disabled:opacity-70 disabled:active:shadow-[0_4px_0_#1e40af] disabled:active:translate-y-0 disabled:cursor-not-allowed"
>
{isSaving ? (
<>
<Loader2 className="animate-spin" size={24} />
Saving...
</>
) : (
<>
<Save size={24} strokeWidth={3} />
Save to Library
</>
)}
</button>
<button
onClick={onSkip}
disabled={isSaving}
className="w-full bg-gray-100 text-gray-500 py-3 rounded-2xl text-lg font-bold hover:bg-gray-200 hover:text-gray-700 transition-colors disabled:opacity-50"
>
Skip & Don't Save
</button>
</div>
</div>
<div className="absolute top-4 right-4">
<button
onClick={onSkip}
disabled={isSaving}
className="p-2 rounded-xl hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-600 disabled:opacity-0"
>
<X size={24} strokeWidth={3} />
</button>
</div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

View file

@ -21,6 +21,7 @@ export const useGame = () => {
const [currentStreak, setCurrentStreak] = useState(0);
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peerRef = useRef<Peer | null>(null);
@ -48,6 +49,7 @@ export const useGame = () => {
setError(null);
setRole('HOST');
const generatedQuiz = await generateQuiz(topic);
setPendingQuizToSave({ quiz: generatedQuiz, topic });
initializeHostGame(generatedQuiz);
} catch (e) {
setError("Failed to generate quiz.");
@ -55,12 +57,19 @@ export const useGame = () => {
}
};
const dismissSavePrompt = () => {
setPendingQuizToSave(null);
};
const startManualCreation = () => {
setRole('HOST');
setGameState('CREATING');
};
const finalizeManualQuiz = (manualQuiz: Quiz) => {
const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => {
if (saveToLibrary) {
setPendingQuizToSave({ quiz: manualQuiz, topic: '' });
}
initializeHostGame(manualQuiz);
};
@ -383,6 +392,7 @@ export const useGame = () => {
return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
pendingQuizToSave, dismissSavePrompt,
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
};
};

View file

@ -88,7 +88,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, source, aiTopic, questions } = body;
if (!title || !source || !questions?.length) {
if (!title?.trim() || !source || !questions?.length) {
res.status(400).json({ error: 'Missing required fields: title, source, questions' });
return;
}

View file

@ -426,6 +426,244 @@ async function runTests() {
await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204);
});
console.log('\nSave Integration Tests (Phase 5):');
let manualSaveQuizId: string | null = null;
let aiSaveQuizId: string | null = null;
await test('POST /api/quizzes manual quiz without aiTopic', async () => {
const manualQuiz = {
title: 'Manual Save Test Quiz',
source: 'manual',
questions: [
{
text: 'What is 1+1?',
timeLimit: 20,
options: [
{ text: '1', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: '2', isCorrect: true, shape: 'diamond', color: 'blue' },
{ text: '3', isCorrect: false, shape: 'circle', color: 'yellow' },
{ text: '4', isCorrect: false, shape: 'square', color: 'green' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', manualQuiz, 201);
const result = data as { id: string };
manualSaveQuizId = result.id;
});
await test('GET manual quiz has null aiTopic', async () => {
if (!manualSaveQuizId) throw new Error('No manual quiz created');
const { data } = await request('GET', `/api/quizzes/${manualSaveQuizId}`);
const quiz = data as Record<string, unknown>;
if (quiz.source !== 'manual') throw new Error('Wrong source');
if (quiz.aiTopic !== null) throw new Error(`Expected null aiTopic, got ${quiz.aiTopic}`);
});
await test('POST /api/quizzes ai_generated with empty aiTopic treated as null', async () => {
const aiQuiz = {
title: 'AI Quiz Empty Topic',
source: 'ai_generated',
aiTopic: '',
questions: [
{
text: 'Test?',
options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }],
},
],
};
const { data } = await request('POST', '/api/quizzes', aiQuiz, 201);
aiSaveQuizId = (data as { id: string }).id;
const { data: getResult } = await request('GET', `/api/quizzes/${aiSaveQuizId}`);
const quiz = getResult as Record<string, unknown>;
if (quiz.aiTopic !== null && quiz.aiTopic !== '') {
throw new Error(`Expected null/empty aiTopic for empty string, got ${quiz.aiTopic}`);
}
});
await test('DELETE cleanup manual save quiz', async () => {
if (manualSaveQuizId) {
await request('DELETE', `/api/quizzes/${manualSaveQuizId}`, undefined, 204);
}
});
await test('DELETE cleanup ai save quiz', async () => {
if (aiSaveQuizId) {
await request('DELETE', `/api/quizzes/${aiSaveQuizId}`, undefined, 204);
}
});
console.log('\nOption Preservation Tests:');
let optionQuizId: string | null = null;
await test('POST /api/quizzes preserves all option fields including reason', async () => {
const quizWithReasons = {
title: 'Quiz With Reasons',
source: 'manual',
questions: [
{
text: 'Capital of France?',
timeLimit: 15,
options: [
{ text: 'London', isCorrect: false, shape: 'triangle', color: 'red', reason: 'London is the capital of UK' },
{ text: 'Paris', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct! Paris is the capital of France' },
{ text: 'Berlin', isCorrect: false, shape: 'circle', color: 'yellow', reason: 'Berlin is the capital of Germany' },
{ text: 'Madrid', isCorrect: false, shape: 'square', color: 'green', reason: 'Madrid is the capital of Spain' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', quizWithReasons, 201);
optionQuizId = (data as { id: string }).id;
const { data: getResult } = await request('GET', `/api/quizzes/${optionQuizId}`);
const quiz = getResult as { questions: { options: { text: string; isCorrect: boolean; shape: string; color: string; reason?: string }[] }[] };
const options = quiz.questions[0].options;
if (options.length !== 4) throw new Error('Expected 4 options');
const parisOpt = options.find(o => o.text === 'Paris');
if (!parisOpt) throw new Error('Paris option not found');
if (!parisOpt.isCorrect) throw new Error('Paris should be correct');
if (parisOpt.reason !== 'Correct! Paris is the capital of France') throw new Error('Paris reason not preserved');
const londonOpt = options.find(o => o.text === 'London');
if (!londonOpt) throw new Error('London option not found');
if (londonOpt.isCorrect) throw new Error('London should not be correct');
if (londonOpt.reason !== 'London is the capital of UK') throw new Error('London reason not preserved');
});
await test('POST /api/quizzes options without reason field are preserved', async () => {
const quizNoReasons = {
title: 'Quiz Without Reasons',
source: 'ai_generated',
aiTopic: 'Geography',
questions: [
{
text: 'Largest ocean?',
options: [
{ text: 'Atlantic', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: 'Pacific', isCorrect: true, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', quizNoReasons, 201);
const quizId = (data as { id: string }).id;
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
const quiz = getResult as { questions: { options: { reason?: string }[] }[] };
const options = quiz.questions[0].options;
const pacificOpt = options.find((o: any) => o.text === 'Pacific');
if (pacificOpt?.reason !== null && pacificOpt?.reason !== undefined) {
throw new Error('Expected null/undefined reason for option without reason');
}
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('DELETE cleanup option quiz', async () => {
if (optionQuizId) {
await request('DELETE', `/api/quizzes/${optionQuizId}`, undefined, 204);
}
});
console.log('\nConcurrent Save Tests:');
await test('Multiple quizzes can be saved by same user', async () => {
const quiz1 = {
title: 'Concurrent Quiz 1',
source: 'manual',
questions: [{ text: 'Q1?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }],
};
const quiz2 = {
title: 'Concurrent Quiz 2',
source: 'ai_generated',
aiTopic: 'Science',
questions: [{ text: 'Q2?', options: [{ text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }] }],
};
const [res1, res2] = await Promise.all([
request('POST', '/api/quizzes', quiz1, 201),
request('POST', '/api/quizzes', quiz2, 201),
]);
const id1 = (res1.data as { id: string }).id;
const id2 = (res2.data as { id: string }).id;
if (id1 === id2) throw new Error('Quiz IDs should be unique');
const { data: listData } = await request('GET', '/api/quizzes');
const list = listData as { id: string; title: string }[];
const found1 = list.find(q => q.id === id1);
const found2 = list.find(q => q.id === id2);
if (!found1) throw new Error('Quiz 1 not in list');
if (!found2) throw new Error('Quiz 2 not in list');
await Promise.all([
request('DELETE', `/api/quizzes/${id1}`, undefined, 204),
request('DELETE', `/api/quizzes/${id2}`, undefined, 204),
]);
});
console.log('\nEdge Case Tests:');
await test('POST /api/quizzes with very long title', async () => {
const longTitle = 'A'.repeat(500);
const quiz = {
title: longTitle,
source: 'manual',
questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }],
};
const { data } = await request('POST', '/api/quizzes', quiz, 201);
const quizId = (data as { id: string }).id;
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
if ((getResult as { title: string }).title !== longTitle) {
throw new Error('Long title not preserved');
}
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('POST /api/quizzes with special characters in title', async () => {
const specialTitle = 'Quiz with "quotes" & <tags> and emoji test';
const quiz = {
title: specialTitle,
source: 'manual',
questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }],
};
const { data } = await request('POST', '/api/quizzes', quiz, 201);
const quizId = (data as { id: string }).id;
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
if ((getResult as { title: string }).title !== specialTitle) {
throw new Error('Special characters not preserved');
}
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('POST /api/quizzes with whitespace-only title returns 400', async () => {
const quiz = {
title: ' ',
source: 'manual',
questions: [{ text: 'Q?', options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }] }],
};
await request('POST', '/api/quizzes', quiz, 400);
});
console.log('\n=== Results ===');
const passed = results.filter((r) => r.passed).length;
const failed = results.filter((r) => !r.passed).length;