Add Quiz feature: 10-question quizzes with progressive scoring (max 40 pts)

- Quizzes table with questions, answer options, attempts, answers
- Question types: multiple_choice, exclusion, true_false, free_text
- Progressive scoring: [1,1,2,2,3,3,4,6,8,10] = max 40 per quiz
- Alpine.js countdown timer per question with auto-submit on timeout
- Admin: CRUD for quizzes + per-question editor, JSON export/import
- Child: quiz overview with best scores, question view, result breakdown
- Nav: Quiz link in child header and admin sidebar
This commit is contained in:
root
2026-05-05 21:14:09 +00:00
parent 213d4b4832
commit 6c6dd26823
21 changed files with 984 additions and 1 deletions
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Child;
use App\Http\Controllers\Controller;
use App\Models\{Quiz, QuizAttempt, QuizAttemptAnswer, QuizQuestion, Subject};
use Illuminate\Http\Request;
class QuizController extends Controller {
public function index() {
$subjects = Subject::all()->keyBy('id');
$quizzes = Quiz::with('subject')->where('active',true)
->withCount('questions')->get()->groupBy('subject_id');
$bestScores = QuizAttempt::where('user_id',auth()->id())
->where('status','completed')
->selectRaw('quiz_id, MAX(score) as best')
->groupBy('quiz_id')->pluck('best','quiz_id');
return view('child.quiz.index', compact('subjects','quizzes','bestScores'));
}
public function start(Quiz $quiz) {
abort_unless($quiz->active, 403);
$attempt = QuizAttempt::firstOrCreate(
['user_id'=>auth()->id(),'quiz_id'=>$quiz->id,'status'=>'in_progress'],
['score'=>0,'current_question'=>0,'started_at'=>now()]
);
return redirect()->route('quiz.question', $attempt);
}
public function question(QuizAttempt $attempt) {
abort_unless($attempt->user_id === auth()->id(), 403);
if ($attempt->status === 'completed') return redirect()->route('quiz.result', $attempt);
$question = $attempt->quiz->questions()->skip($attempt->current_question)->first();
$pointsIfCorrect = QuizAttempt::POINTS[$attempt->current_question];
return view('child.quiz.question', compact('attempt','question','pointsIfCorrect'));
}
public function answer(Request $r, QuizAttempt $attempt) {
abort_unless($attempt->user_id === auth()->id(), 403);
abort_if($attempt->status === 'completed', 400);
$question = $attempt->quiz->questions()->skip($attempt->current_question)->first();
$possible = QuizAttempt::POINTS[$attempt->current_question];
$timedOut = $r->input('timeout') === '1';
$answerGiven = $r->input('answer','');
$isCorrect = false;
$pointsEarned = 0;
if (!$timedOut && $answerGiven !== '') {
$isCorrect = $this->check($question, $answerGiven);
$pointsEarned = $isCorrect ? $possible : 0;
}
QuizAttemptAnswer::create([
'quiz_attempt_id' => $attempt->id,
'quiz_question_id' => $question->id,
'answer_given' => $timedOut ? '__timeout__' : $answerGiven,
'is_correct' => $isCorrect,
'points_earned' => $pointsEarned,
]);
session()->flash('last_answer', [
'correct' => $isCorrect,
'points' => $pointsEarned,
'possible' => $possible,
'timeout' => $timedOut,
]);
$next = $attempt->current_question + 1;
$totalQuestions = $attempt->quiz->questions()->count();
if ($next >= $totalQuestions || $next >= 10) {
$total = QuizAttemptAnswer::where('quiz_attempt_id',$attempt->id)->sum('points_earned');
$attempt->update(['score'=>$total,'current_question'=>$next,'status'=>'completed','completed_at'=>now()]);
auth()->user()->increment('points', $total);
return redirect()->route('quiz.result', $attempt);
}
$attempt->update(['current_question'=>$next]);
return redirect()->route('quiz.question', $attempt);
}
public function result(QuizAttempt $attempt) {
abort_unless($attempt->user_id === auth()->id(), 403);
abort_unless($attempt->status === 'completed', 400);
$answers = $attempt->answers()->with('question.answerOptions')->get();
return view('child.quiz.result', compact('attempt','answers'));
}
private function check(QuizQuestion $q, string $answer): bool {
return match($q->type) {
'multiple_choice','exclusion' => ($opt = $q->answerOptions()->where('is_correct',true)->first())
&& (string)$opt->id === $answer,
'true_false','free_text' => strtolower(trim($answer)) === strtolower(trim((string)$q->correct_answer)),
default => false,
};
}
}