feat(tasks): semantic slugs + per-slug question banks + Lerni AI-helper

Before:
  - Task slug "t1" was generic, meaningless to user and crawlers.
  - Question bank was keyed by `color`, so EVERY math task (multiplikation,
    geometrie, ...) shared identical questions.
  - No way for child / parent to ask AI for an explanation step-by-step.

Now (Workbook + Lerni variant from Übungsseite template):

Semantic slugs in config/tasks.php:
  math-multiplikation-bis-100
  math-geometrie-flaechen
  deutsch-wortarten-nomen-verben
  deutsch-gedicht-lernen
  englisch-vocab-animals
  englisch-past-simple
  sachkunde-wasserkreislauf

Each task now has:
  slug, title, topic, subject, meta, icon, color, points, status, due

config/task-questions.php re-keyed by `slug` (not color):
  Every task gets its OWN question set — math-multiplikation has
  multiplication questions, math-geometrie has area/perimeter, deutsch
  has wortarten/grammar, etc.

Each question now has 5 keys:
  question, options[4], correct, lerni_hint, lerni_steps[]

Lerni AI-helper (right sidebar in Workbook layout):
  - Closed by default; "Frag Lerni" button reveals
  - Hint box (💡) with short text-explanation
  - Numbered Lösungsweg with method-steps (colored badges per subject)
  - Shortlink URL `?lerni=1` (parents/teachers can deep-link to a
    pre-opened explanation)
  - #[Url(as: 'lerni')] hydrates from URL
  - Auto-closes on nextStep (fresh question = fresh thinking)

Route updated:
  Route::get('/tasks/{slug}/run', \App\Livewire\Tasks\Run::class)
    ->where('slug', '[a-z0-9-]+')   // tighter than whereAlphaNumeric;
                                     // allows hyphens, rejects UPPER
    ->name('tasks.run')

Tasks/Index::startTask / repeatTask: $slug arg + redirectRoute to
tasks.run with slug.

View livewire/tasks/run.blade.php switched from single column to
2-column grid (max-w-6xl): col-span-2 question card + col-span-1
Lerni aside. Per-subject color theming (focus-ring, progress bar,
step badges, option border on feedback).

Cleanup:
  - Removed App\Livewire\Tasks\Modals\Runner (obsolete; page-based now)
  - Removed livewire.tasks.modals.runner view
  - Removed TasksRunnerModalTest

Tests (TasksRunPageTest 22, TaskQuestionBankTest 6, TasksStartButton 8):
  - Semantic slug validation (no t[0-9]+ patterns)
  - Per-slug question selection (math vs deutsch vs english vs sachkunde)
  - math-multiplikation != math-geometrie (different content same color)
  - Lerni-panel renders; lerni-open trigger; toggleLerni reveals steps
  - Shortlink contains slug + ?lerni=1
  - ?lerni=1 query auto-opens helper
  - Route 404 for UPPER chars and unknown slugs
  - startTask redirects to tasks.run with slug

Verification:
  - 188 tests passed, 0 failed, 0 skipped (1024 assertions)
  - npm build OK
  - All 8 page routes 302 unauth
  - All 7 task slug routes 302 unauth
  - tasks/UPPER/run 404 (route regex)
  - 7 modal aliases resolve (Runner removed)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
Boban Blaskovic 2026-05-22 22:48:05 +02:00
parent cae775ba93
commit d3f976b18e
13 changed files with 648 additions and 497 deletions

View File

@ -42,26 +42,24 @@ class Index extends Component
/** @var array<int,string> */
public array $startedKeys = [];
public function startTask(string $key): void
public function startTask(string $slug): void
{
$task = collect($this->tasks)->firstWhere('key', $key);
$task = collect($this->tasks)->firstWhere('slug', $slug);
if ($task === null) {
return;
}
if (! in_array($key, $this->startedKeys, true)) {
$this->startedKeys[] = $key;
if (! in_array($slug, $this->startedKeys, true)) {
$this->startedKeys[] = $slug;
}
// Start navigates to the dedicated Run page (subject-specific quiz)
// instead of opening a modal — same data but per-subject questions
// and a structured URL.
$this->redirectRoute('tasks.run', ['key' => $key], navigate: true);
// Start navigates to the dedicated Run page (per-slug quiz + Lerni AI).
$this->redirectRoute('tasks.run', ['slug' => $slug], navigate: true);
}
public function repeatTask(string $key): void
public function repeatTask(string $slug): void
{
$this->startTask($key);
$this->startTask($slug);
}
#[Computed]

View File

@ -1,74 +0,0 @@
<?php
namespace App\Livewire\Tasks\Modals;
use Illuminate\Contracts\View\View;
use LivewireUI\Modal\ModalComponent;
class Runner extends ModalComponent
{
public array $task = [];
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $finished = false;
public ?string $answer = null;
public int $correctAnswers = 0;
/** @var array<int,array<string,mixed>> Placeholder mini-quiz; future = DB-loaded items */
protected array $steps = [
['question' => 'Was ergibt 12 × 8?', 'options' => ['64', '88', '96', '102'], 'correct' => 2],
['question' => 'Was ergibt 15 × 7?', 'options' => ['95', '105', '115', '125'], 'correct' => 1],
['question' => 'Was ergibt 9 × 11?', 'options' => ['91', '99', '108', '111'], 'correct' => 1],
['question' => 'Was ergibt 14 × 6?', 'options' => ['72', '78', '84', '88'], 'correct' => 2],
];
public function mount(array $task = []): void
{
$this->task = $task;
$this->totalSteps = count($this->steps);
}
public function currentQuestion(): array
{
return $this->steps[$this->currentStep - 1] ?? $this->steps[0];
}
public function submitAnswer(int $optionIndex): void
{
$q = $this->currentQuestion();
if ($optionIndex === ($q['correct'] ?? -1)) {
$this->correctAnswers++;
}
if ($this->currentStep >= $this->totalSteps) {
$this->finished = true;
return;
}
$this->currentStep++;
$this->answer = null;
}
public function restart(): void
{
$this->currentStep = 1;
$this->finished = false;
$this->correctAnswers = 0;
$this->answer = null;
}
public static function modalMaxWidth(): string
{
return '2xl';
}
public static function closeModalOnClickAway(): bool
{
return false;
}
public function render(): View
{
return view('livewire.tasks.modals.runner');
}
}

View File

@ -5,13 +5,14 @@ namespace App\Livewire\Tasks;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Lazy;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dashboard', ['active' => 'tasks'])]
#[Lazy]
class Run extends Component
{
public string $key = '';
public string $slug = '';
public array $task = [];
public array $questions = [];
public int $currentStep = 1;
@ -21,17 +22,21 @@ class Run extends Component
public ?int $lastAnswerIndex = null;
public ?bool $lastAnswerCorrect = null;
public function mount(string $key): void
{
$task = $this->findTask($key);
abort_if($task === null, 404, "Task '{$key}' not found.");
/** Lerni AI helper panel — toggled via showLerni; can be shared via URL (?lerni=1). */
#[Url(as: 'lerni', keep: false)]
public bool $showLerni = false;
$this->key = $key;
public function mount(string $slug): void
{
$task = $this->findTask($slug);
abort_if($task === null, 404, "Task '{$slug}' not found.");
$this->slug = $slug;
$this->task = $task;
$this->questions = config("task-questions.{$task['color']}", []);
$this->questions = config("task-questions.{$slug}", []);
$this->totalSteps = count($this->questions);
abort_if($this->totalSteps === 0, 404, "No questions configured for color '{$task['color']}'.");
abort_if($this->totalSteps === 0, 404, "No questions configured for task '{$slug}'.");
}
public static function placeholder(array $params = []): View
@ -66,6 +71,13 @@ class Run extends Component
$this->currentStep++;
$this->lastAnswerIndex = null;
$this->lastAnswerCorrect = null;
// Auto-close Lerni helper when moving to next question
$this->showLerni = false;
}
public function toggleLerni(): void
{
$this->showLerni = ! $this->showLerni;
}
public function restart(): void
@ -75,12 +87,12 @@ class Run extends Component
$this->correctAnswers = 0;
$this->lastAnswerIndex = null;
$this->lastAnswerCorrect = null;
$this->showLerni = false;
}
/** Locate task by key — placeholder data (later DB-backed). */
protected function findTask(string $key): ?array
protected function findTask(string $slug): ?array
{
return collect(config('tasks', []))->firstWhere('key', $key);
return collect(config('tasks', []))->firstWhere('slug', $slug);
}
public function render(): View

View File

@ -1,63 +1,270 @@
<?php
/**
* Task Question Bank keyed by subject `color` token.
* Task Question Bank keyed by task `slug`.
*
* Each entry is an array of question dicts:
* - question: prompt (string)
* - options: array<int,string> of 4 answer choices
* - correct: int index (0..3) of correct option
* Each entry: array of question dicts:
* - question: prompt
* - options: [4 strings]
* - correct: int 0..3
* - lerni_hint: short step-by-step Lerni explanation (KI-Helper)
* - lerni_steps: array of method-steps (KI-Helper "Lösungsweg")
*
* Subjects:
* primary -> Mathematik
* rose -> Deutsch
* violet -> Englisch
* green -> Sachkunde
* amber -> Musik / Sport / Philosophie
*
* Future: replace with DB-backed Question + Answer models, scoped by tenant.
* Future: replace with DB-backed Question + Answer models.
*/
return [
'primary' => [
['question' => 'Was ergibt 12 × 8?', 'options' => ['64', '88', '96', '102'], 'correct' => 2],
['question' => 'Was ergibt 15 × 7?', 'options' => ['95', '105', '115', '125'], 'correct' => 1],
['question' => 'Was ergibt 9 × 11?', 'options' => ['91', '99', '108', '111'], 'correct' => 1],
['question' => 'Was ergibt 14 × 6?', 'options' => ['72', '78', '84', '88'], 'correct' => 2],
['question' => 'Was ergibt 144 ÷ 12?', 'options' => ['10', '11', '12', '13'], 'correct' => 2],
'math-multiplikation-bis-100' => [
[
'question' => 'Was ergibt 12 × 8?',
'options' => ['64', '88', '96', '102'],
'correct' => 2,
'lerni_hint' => 'Multipliziere die Einer und Zehner getrennt: 8×2=16 und 8×10=80. Beides zusammen = 96.',
'lerni_steps' => ['Zerlege 12 in 10 + 2', '8 × 10 = 80', '8 × 2 = 16', '80 + 16 = 96'],
],
[
'question' => 'Was ergibt 15 × 7?',
'options' => ['95', '105', '115', '125'],
'correct' => 1,
'lerni_hint' => 'Zerlege 15 in 10 + 5. 7×10=70 und 7×5=35. Zusammen = 105.',
'lerni_steps' => ['15 = 10 + 5', '7 × 10 = 70', '7 × 5 = 35', '70 + 35 = 105'],
],
[
'question' => 'Was ergibt 9 × 11?',
'options' => ['91', '99', '108', '111'],
'correct' => 1,
'lerni_hint' => '11er-Trick: 9 × 11 = 9 × 10 + 9 × 1 = 90 + 9 = 99.',
'lerni_steps' => ['9 × 10 = 90', '9 × 1 = 9', '90 + 9 = 99'],
],
[
'question' => 'Was ergibt 14 × 6?',
'options' => ['72', '78', '84', '88'],
'correct' => 2,
'lerni_hint' => '14 = 10 + 4. 6×10=60, 6×4=24, zusammen = 84.',
'lerni_steps' => ['14 = 10 + 4', '6 × 10 = 60', '6 × 4 = 24', '60 + 24 = 84'],
],
[
'question' => 'Was ergibt 144 ÷ 12?',
'options' => ['10', '11', '12', '13'],
'correct' => 2,
'lerni_hint' => 'Welche Zahl × 12 ergibt 144? Die 12 selbst, denn 12 × 12 = 144.',
'lerni_steps' => ['Suche x mit x × 12 = 144', 'Probier x = 12', '12 × 12 = 144 ✓'],
],
],
'rose' => [
['question' => 'Welches Wort ist ein Nomen?', 'options' => ['laufen', 'schnell', 'Hund', 'rot'], 'correct' => 2],
['question' => 'Welches Wort ist ein Verb?', 'options' => ['Tisch', 'springen', 'grün', 'Lehrer'], 'correct' => 1],
['question' => 'Welches Wort ist ein Adjektiv?', 'options' => ['Schule', 'lesen', 'glücklich', 'gehen'], 'correct' => 2],
['question' => 'Wie wird "Buch" im Plural geschrieben?', 'options' => ['Buchs', 'Buchen', 'Bücher', 'Buches'], 'correct' => 2],
['question' => 'Welcher Artikel passt zu "Sonne"?', 'options' => ['der', 'die', 'das', 'den'], 'correct' => 1],
'math-geometrie-flaechen' => [
[
'question' => 'Fläche eines Rechtecks 5cm × 4cm?',
'options' => ['9 cm²', '18 cm²', '20 cm²', '25 cm²'],
'correct' => 2,
'lerni_hint' => 'Fläche Rechteck = Länge × Breite = 5 × 4 = 20 cm².',
'lerni_steps' => ['Formel: A = L × B', 'L = 5, B = 4', 'A = 5 × 4 = 20 cm²'],
],
[
'question' => 'Fläche eines Quadrats mit Seitenlänge 6cm?',
'options' => ['12 cm²', '24 cm²', '30 cm²', '36 cm²'],
'correct' => 3,
'lerni_hint' => 'Quadrat: A = Seite × Seite = 6 × 6 = 36 cm².',
'lerni_steps' => ['Formel: A = s × s', 's = 6', 'A = 6 × 6 = 36 cm²'],
],
[
'question' => 'Umfang eines Quadrats mit Seitenlänge 5cm?',
'options' => ['10 cm', '15 cm', '20 cm', '25 cm'],
'correct' => 2,
'lerni_hint' => 'Umfang = 4 × Seite = 4 × 5 = 20 cm.',
'lerni_steps' => ['U = 4 × s', 's = 5', 'U = 4 × 5 = 20 cm'],
],
[
'question' => 'Wie viele Ecken hat ein Dreieck?',
'options' => ['2', '3', '4', '5'],
'correct' => 1,
'lerni_hint' => 'Ein Dreieck hat per Definition 3 Ecken und 3 Seiten.',
'lerni_steps' => ['Dreieck = "tri" (drei) + "Eck"', '3 Seiten verbinden = 3 Ecken'],
],
[
'question' => 'Welche Form hat 4 gleich lange Seiten?',
'options' => ['Dreieck', 'Quadrat', 'Kreis', 'Trapez'],
'correct' => 1,
'lerni_hint' => 'Quadrat — alle 4 Seiten sind gleich lang, alle Ecken 90°.',
'lerni_steps' => ['4 gleiche Seiten ⇒ Quadrat oder Raute', '90° Ecken ⇒ Quadrat'],
],
],
'violet' => [
['question' => 'What is the past tense of "go"?', 'options' => ['goed', 'goes', 'gone', 'went'], 'correct' => 3],
['question' => 'Translate "Katze" to English:', 'options' => ['dog', 'cat', 'cow', 'mouse'], 'correct' => 1],
['question' => 'Which is the correct plural of "child"?', 'options' => ['childs', 'childes', 'children', 'childer'], 'correct' => 2],
['question' => 'Pick the correct: "She ___ a book."', 'options' => ['read', 'reads', 'reading', 'reader'], 'correct' => 1],
['question' => 'Translate "Apfel" to English:', 'options' => ['apple', 'orange', 'banana', 'lemon'], 'correct' => 0],
'deutsch-wortarten-nomen-verben' => [
[
'question' => 'Welches Wort ist ein Nomen?',
'options' => ['laufen', 'schnell', 'Hund', 'rot'],
'correct' => 2,
'lerni_hint' => 'Nomen sind Dinge, Personen oder Tiere. "Hund" ist ein Tier — also ein Nomen.',
'lerni_steps' => ['Nomen = Namenwort', 'Großgeschrieben', 'Beispiele: Hund, Tisch, Lina'],
],
[
'question' => 'Welches Wort ist ein Verb?',
'options' => ['Tisch', 'springen', 'grün', 'Lehrer'],
'correct' => 1,
'lerni_hint' => 'Verben sind Tätigkeitswörter. "springen" beschreibt eine Tätigkeit.',
'lerni_steps' => ['Verb = Tätigkeitswort', 'Antwort auf "Was tut jemand?"', 'springen = Tätigkeit ✓'],
],
[
'question' => 'Welches Wort ist ein Adjektiv?',
'options' => ['Schule', 'lesen', 'glücklich', 'gehen'],
'correct' => 2,
'lerni_hint' => 'Adjektive beschreiben Eigenschaften. "glücklich" beschreibt wie jemand sich fühlt.',
'lerni_steps' => ['Adjektiv = Eigenschaftswort', 'Antwort auf "Wie ist etwas?"', 'glücklich beschreibt Gefühl ✓'],
],
[
'question' => 'Wie wird "Buch" im Plural geschrieben?',
'options' => ['Buchs', 'Buchen', 'Bücher', 'Buches'],
'correct' => 2,
'lerni_hint' => 'Plural von Buch ist "Bücher" — mit Umlaut ü und -er-Endung.',
'lerni_steps' => ['Buch → Bücher', 'Umlaut: u → ü', 'Endung: + er'],
],
[
'question' => 'Welcher Artikel passt zu "Sonne"?',
'options' => ['der', 'die', 'das', 'den'],
'correct' => 1,
'lerni_hint' => '"Die Sonne" — Sonne ist im Deutschen weiblich (feminin).',
'lerni_steps' => ['Genus prüfen: m / f / n', 'Sonne = feminin', 'Artikel feminin Nominativ: die'],
],
],
'green' => [
['question' => 'Was passiert beim Verdampfen von Wasser?', 'options' => ['Es wird flüssig', 'Es wird zu Dampf', 'Es gefriert', 'Es verschwindet'], 'correct' => 1],
['question' => 'Wie viele Beine hat eine Spinne?', 'options' => ['6', '8', '10', '12'], 'correct' => 1],
['question' => 'Welches Tier ist KEIN Säugetier?', 'options' => ['Hund', 'Pferd', 'Forelle', 'Katze'], 'correct' => 2],
['question' => 'In welcher Jahreszeit fallen die Blätter?', 'options' => ['Frühling', 'Sommer', 'Herbst', 'Winter'], 'correct' => 2],
['question' => 'Welcher Planet ist der Sonne am nächsten?', 'options' => ['Erde', 'Mars', 'Merkur', 'Venus'], 'correct' => 2],
'deutsch-gedicht-lernen' => [
[
'question' => 'Was reimt sich auf "Haus"?',
'options' => ['Buch', 'Maus', 'Tisch', 'Auto'],
'correct' => 1,
'lerni_hint' => 'Wörter reimen sich, wenn sie ähnlich klingen am Ende. Haus — Maus reimt sich (-aus).',
'lerni_steps' => ['Reim: gleicher Endklang', 'Haus endet auf "-aus"', 'Maus endet auf "-aus" ✓'],
],
[
'question' => 'Wie nennt man Sätze, die reimen?',
'options' => ['Prosa', 'Drama', 'Vers', 'Roman'],
'correct' => 2,
'lerni_hint' => 'Reimende Zeilen in Gedichten heißen Verse.',
'lerni_steps' => ['Vers = Gedichtzeile', 'Mehrere Verse = Strophe'],
],
[
'question' => 'Was sagt man oft am Anfang eines Gedichts?',
'options' => ['Tschüss', 'Es war einmal', 'Hallo', 'Auf Wiedersehen'],
'correct' => 1,
'lerni_hint' => '"Es war einmal" leitet oft Geschichten und Gedichte ein.',
'lerni_steps' => ['Erzähl-Einleitung', 'Klassisch für Märchen und Gedichte'],
],
[
'question' => 'Wie lernt man ein Gedicht am besten?',
'options' => ['Nur einmal lesen', 'Auswendig laut sprechen', 'Schnell überfliegen', 'Nicht lernen'],
'correct' => 1,
'lerni_hint' => 'Gedichte lernt man am besten, wenn man sie laut spricht — mehrfach.',
'lerni_steps' => ['Lies langsam vor', 'Wiederhole laut', 'Verse einzeln üben', 'Ganz aufsagen'],
],
],
'amber' => [
['question' => 'Wie viele Linien hat ein Notensystem?', 'options' => ['3', '4', '5', '6'], 'correct' => 2],
['question' => 'Welche Note ist die Längste?', 'options' => ['Achtel', 'Viertel', 'Halbe', 'Ganze'], 'correct' => 3],
['question' => 'Was bedeutet "forte" in der Musik?', 'options' => ['leise', 'laut', 'schnell', 'langsam'], 'correct' => 1],
['question' => 'Wie viele Saiten hat eine normale Gitarre?', 'options' => ['4', '5', '6', '7'], 'correct' => 2],
['question' => 'Was ist ein Komponist?', 'options' => ['Lehrer', 'Musik-Erfinder', 'Sänger', 'Instrumentenbauer'], 'correct' => 1],
'englisch-vocab-animals' => [
[
'question' => 'Translate "Katze" to English:',
'options' => ['dog', 'cat', 'cow', 'mouse'],
'correct' => 1,
'lerni_hint' => 'Katze = cat. Eselsbrücke: K-atze → c-at, beide drei Buchstaben mit -at-.',
'lerni_steps' => ['Listen: "cat" /kæt/', 'Sound: K-Anfang wie Katze', 'cat ✓'],
],
[
'question' => 'Translate "Hund" to English:',
'options' => ['cat', 'bird', 'dog', 'fish'],
'correct' => 2,
'lerni_hint' => 'Hund = dog. "Dog" hat 3 Buchstaben, wie ein kurzes Bellen.',
'lerni_steps' => ['dog /dɒɡ/', 'kurzes Wort, kurzer Hund-Laut'],
],
[
'question' => 'Translate "Maus" to English:',
'options' => ['mouse', 'horse', 'house', 'cheese'],
'correct' => 0,
'lerni_hint' => 'Maus = mouse. Ähnlich klingt es: "Maus" → "mouse".',
'lerni_steps' => ['Maus → mouse', 'au → ou', 's bleibt s'],
],
[
'question' => 'What is the plural of "mouse"?',
'options' => ['mouses', 'mice', 'mouses', 'mouse'],
'correct' => 1,
'lerni_hint' => 'Unregelmäßiger Plural: mouse → mice. NICHT "mouses".',
'lerni_steps' => ['mouse = Einzahl', 'mice = Mehrzahl', 'unregelmäßig: kein "-s"'],
],
[
'question' => 'Translate "Vogel" to English:',
'options' => ['fish', 'bird', 'cat', 'frog'],
'correct' => 1,
'lerni_hint' => 'Vogel = bird. Eselsbrücke: B-ird wie B-aum, wo Vögel sitzen.',
'lerni_steps' => ['bird /bɜːd/', 'kurzes Wort, weicher Klang'],
],
],
'englisch-past-simple' => [
[
'question' => 'What is the past tense of "go"?',
'options' => ['goed', 'goes', 'gone', 'went'],
'correct' => 3,
'lerni_hint' => 'go → went (irregular). Past tense komplett anders als die Grundform.',
'lerni_steps' => ['go = Grundform', 'unregelmäßig: kein -ed', 'past simple: went'],
],
[
'question' => 'Past tense of "play"?',
'options' => ['played', 'plaied', 'play', 'plays'],
'correct' => 0,
'lerni_hint' => 'play ist regelmäßig: einfach -ed dranhängen → played.',
'lerni_steps' => ['Regelmäßig + ed', 'play + ed = played'],
],
[
'question' => 'Pick the correct past: "Yesterday I ___ to school."',
'options' => ['go', 'goes', 'went', 'gone'],
'correct' => 2,
'lerni_hint' => '"Yesterday" = gestern → Past Simple. go → went.',
'lerni_steps' => ['Yesterday = past', 'go past = went', 'I went to school ✓'],
],
[
'question' => 'Past tense of "see"?',
'options' => ['seed', 'saw', 'sees', 'seen'],
'correct' => 1,
'lerni_hint' => 'see → saw (irregular). "Seen" ist Past Participle, nicht Past Simple.',
'lerni_steps' => ['see (Grundform)', 'saw (past simple)', 'seen (past participle)'],
],
],
'sachkunde-wasserkreislauf' => [
[
'question' => 'Was passiert beim Verdampfen von Wasser?',
'options' => ['Es wird flüssig', 'Es wird zu Dampf', 'Es gefriert', 'Es verschwindet'],
'correct' => 1,
'lerni_hint' => 'Wasser verdunstet bei Wärme: flüssig → gasförmig (Dampf).',
'lerni_steps' => ['Sonne erwärmt Wasser', 'Wasser steigt als Dampf auf', 'Dampf = gasförmig'],
],
[
'question' => 'Wie heißt der Vorgang, wenn Dampf zu Wassertropfen wird?',
'options' => ['Verdampfung', 'Kondensation', 'Versickerung', 'Niederschlag'],
'correct' => 1,
'lerni_hint' => 'Kondensation = Gas → Flüssigkeit (Dampf wird zu Wolken/Tröpfchen).',
'lerni_steps' => ['Dampf kühlt ab', 'wird zu kleinen Tropfen', 'Tropfen = Kondensation'],
],
[
'question' => 'Was passiert bei Niederschlag?',
'options' => ['Wasser steigt auf', 'Wasser fällt vom Himmel', 'Wasser gefriert', 'Wasser verdampft'],
'correct' => 1,
'lerni_hint' => 'Niederschlag = Regen oder Schnee, Wasser fällt aus Wolken zur Erde.',
'lerni_steps' => ['Wolken werden schwer', 'Tropfen fallen', 'als Regen oder Schnee'],
],
[
'question' => 'Wohin fließt Regenwasser meistens?',
'options' => ['In den Himmel', 'In Flüsse und Seen', 'In die Sonne', 'Ins Weltall'],
'correct' => 1,
'lerni_hint' => 'Regenwasser fließt zu Bächen, Flüssen, Seen und schließlich ins Meer.',
'lerni_steps' => ['Regen fällt', 'sammelt sich in Bächen', 'Bach → Fluss → See → Meer'],
],
[
'question' => 'Wie viele Phasen hat der Wasserkreislauf?',
'options' => ['2', '3', '4', '5'],
'correct' => 2,
'lerni_hint' => 'Vier Phasen: Verdunstung, Kondensation, Niederschlag, Versickerung/Abfluss.',
'lerni_steps' => ['1. Verdunstung', '2. Kondensation', '3. Niederschlag', '4. Abfluss'],
],
],
];

View File

@ -1,21 +1,86 @@
<?php
/**
* Placeholder task list keyed by `key`. Each task has:
* key, title, meta, icon, color, points, status, due
* Placeholder task list keyed by `slug` (semantic, URL-safe).
*
* `color` ties into config/task-questions.php for the quiz bank used by
* the Tasks\Run page.
* Each task:
* slug unique, [a-z0-9-]+ (URL segment)
* title human-readable
* topic short category (e.g. "Multiplikation", "Wortarten")
* meta display string (subject · count · time)
* icon single-char unicode for color tile
* color design-token color (primary|rose|violet|green|amber)
* subject Mathematik / Deutsch / Englisch / Sachkunde / Musik
* points reward on success
* status open | done
* due display string
*
* Future: replace with DB-backed Task model scoped by tenant + child + curriculum.
* Quizzes for each slug live in config/task-questions.php under the same slug.
* Future: DB-backed Task + TaskItem models scoped by tenant + curriculum.
*/
return [
['key' => 't1', 'title' => 'Multiplikation bis 100', 'meta' => 'Mathematik · 12 Aufgaben · 15 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 120, 'status' => 'open', 'due' => 'Heute'],
['key' => 't2', 'title' => 'Wortarten: Nomen & Verben', 'meta' => 'Deutsch · Lese-Aufgabe · 10 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 80, 'status' => 'open', 'due' => 'Heute'],
['key' => 't3', 'title' => 'Vocab Review: Animals', 'meta' => 'Englisch · Karteikarten · 8 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 60, 'status' => 'open', 'due' => 'Heute'],
['key' => 't4', 'title' => 'Kreislauf des Wassers', 'meta' => 'Sachkunde · Video + Quiz · 12 Min.', 'icon' => '🌱', 'color' => 'green', 'points' => 100, 'status' => 'open', 'due' => 'Heute'],
['key' => 't5', 'title' => 'Geometrie: Flächen', 'meta' => 'Mathematik · Übung · 20 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 150, 'status' => 'open', 'due' => 'Morgen'],
['key' => 't6', 'title' => 'Gedicht lernen', 'meta' => 'Deutsch · Lese-Aufgabe · 15 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 90, 'status' => 'done', 'due' => 'Gestern'],
['key' => 't7', 'title' => 'Past Simple Übung', 'meta' => 'Englisch · Übung · 10 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 70, 'status' => 'done', 'due' => 'Gestern'],
[
'slug' => 'math-multiplikation-bis-100',
'title' => 'Multiplikation bis 100',
'topic' => 'Multiplikation',
'subject' => 'Mathematik',
'meta' => 'Mathematik · 5 Aufgaben · 10 Min.',
'icon' => '∑', 'color' => 'primary',
'points' => 120, 'status' => 'open', 'due' => 'Heute',
],
[
'slug' => 'math-geometrie-flaechen',
'title' => 'Geometrie: Flächen',
'topic' => 'Geometrie',
'subject' => 'Mathematik',
'meta' => 'Mathematik · 5 Aufgaben · 15 Min.',
'icon' => '∑', 'color' => 'primary',
'points' => 150, 'status' => 'open', 'due' => 'Morgen',
],
[
'slug' => 'deutsch-wortarten-nomen-verben',
'title' => 'Wortarten: Nomen & Verben',
'topic' => 'Wortarten',
'subject' => 'Deutsch',
'meta' => 'Deutsch · 5 Aufgaben · 10 Min.',
'icon' => 'A', 'color' => 'rose',
'points' => 80, 'status' => 'open', 'due' => 'Heute',
],
[
'slug' => 'deutsch-gedicht-lernen',
'title' => 'Gedicht lernen',
'topic' => 'Lesen',
'subject' => 'Deutsch',
'meta' => 'Deutsch · 4 Aufgaben · 15 Min.',
'icon' => 'A', 'color' => 'rose',
'points' => 90, 'status' => 'done', 'due' => 'Gestern',
],
[
'slug' => 'englisch-vocab-animals',
'title' => 'Vocab Review: Animals',
'topic' => 'Vokabeln',
'subject' => 'Englisch',
'meta' => 'Englisch · 5 Aufgaben · 8 Min.',
'icon' => 'E', 'color' => 'violet',
'points' => 60, 'status' => 'open', 'due' => 'Heute',
],
[
'slug' => 'englisch-past-simple',
'title' => 'Past Simple Übung',
'topic' => 'Grammar',
'subject' => 'Englisch',
'meta' => 'Englisch · 4 Aufgaben · 10 Min.',
'icon' => 'E', 'color' => 'violet',
'points' => 70, 'status' => 'done', 'due' => 'Gestern',
],
[
'slug' => 'sachkunde-wasserkreislauf',
'title' => 'Kreislauf des Wassers',
'topic' => 'Natur',
'subject' => 'Sachkunde',
'meta' => 'Sachkunde · 5 Aufgaben · 12 Min.',
'icon' => '🌱', 'color' => 'green',
'points' => 100, 'status' => 'open', 'due' => 'Heute',
],
];

View File

@ -69,7 +69,7 @@
@foreach($this->filteredTasks as $t)
@php $c = $colorMap[$t['color']]; @endphp
<div
wire:key="task-{{ $t['key'] }}"
wire:key="task-{{ $t['slug'] }}"
class="group grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0"
data-testid="task-row"
>
@ -98,13 +98,13 @@
</button>
@if($t['status'] === 'open')
<x-btn variant="primary" size="sm"
wire:click="startTask('{{ $t['key'] }}')"
wire:click="startTask('{{ $t['slug'] }}')"
data-testid="task-start-btn">
Starten
</x-btn>
@else
<x-btn size="sm"
wire:click="repeatTask('{{ $t['key'] }}')"
wire:click="repeatTask('{{ $t['slug'] }}')"
data-testid="task-repeat-btn">
Wiederholen
</x-btn>

View File

@ -1,98 +0,0 @@
@php
$q = $this->currentQuestion();
$progressPercent = $totalSteps > 0 ? (int) round(($currentStep / $totalSteps) * 100) : 0;
@endphp
<div data-testid="task-runner-modal">
<header class="flex items-center justify-between gap-4 p-5 border-b border-line">
<div class="flex items-center gap-3 min-w-0">
<div class="w-10 h-10 rounded-lg bg-primary-soft text-primary-ink grid place-items-center font-bold text-lg shrink-0">
{{ $task['icon'] ?? '∑' }}
</div>
<div class="min-w-0">
<div class="text-[10px] uppercase tracking-[0.12em] text-ink-3 font-semibold">{{ $task['meta'] ?? '' }}</div>
<div class="font-bold text-base text-ink-1 truncate">{{ $task['title'] ?? 'Aufgabe' }}</div>
</div>
</div>
<button type="button" wire:click="closeModal" aria-label="Schließen" class="text-ink-3 hover:text-ink-1 transition-colors p-1 shrink-0">
<x-heroicon-o-x-mark class="w-5 h-5" />
</button>
</header>
@if(! $finished)
{{-- Progress bar --}}
<div class="px-5 pt-4">
<div class="flex justify-between items-baseline mb-2">
<span class="text-xs font-semibold text-ink-2 font-mono tabular-nums">Frage {{ $currentStep }} / {{ $totalSteps }}</span>
<span class="text-xs font-semibold text-amber-ink font-mono tabular-nums">+{{ (int) ($task['points'] ?? 0) }} Pkt.</span>
</div>
<div class="h-1.5 rounded-full bg-bg-base overflow-hidden">
<div class="h-full rounded-full bg-primary-ink transition-all duration-500" style="width:{{ $progressPercent }}%;"></div>
</div>
</div>
{{-- Question --}}
<div class="p-5">
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-2">Aufgabe</div>
<div class="text-xl font-bold tracking-tight text-ink-1 mb-5" data-testid="runner-question">
{{ $q['question'] }}
</div>
<div class="grid grid-cols-2 gap-3" wire:key="step-{{ $currentStep }}">
@foreach($q['options'] as $i => $opt)
<button
type="button"
wire:click="submitAnswer({{ $i }})"
wire:loading.attr="disabled"
wire:target="submitAnswer"
class="px-4 py-4 rounded-lg bg-bg-soft border-2 border-line text-ink-1 font-semibold text-base hover:border-primary hover:bg-primary-soft transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
data-testid="runner-option-{{ $i }}"
>
{{ $opt }}
</button>
@endforeach
</div>
</div>
<footer class="px-5 pb-5 pt-2 flex justify-between items-center text-xs text-ink-3">
<span>Klick eine Antwort an, um fortzufahren.</span>
<button type="button" wire:click="closeModal" class="hover:text-ink-1 underline">Abbrechen</button>
</footer>
@else
{{-- Finished state --}}
@php
$passed = $correctAnswers >= (int) ceil($totalSteps / 2);
$percent = $totalSteps > 0 ? (int) round(($correctAnswers / $totalSteps) * 100) : 0;
@endphp
<div class="p-8 text-center" data-testid="runner-finished">
<div class="w-16 h-16 rounded-full grid place-items-center mx-auto mb-4 {{ $passed ? 'bg-green-soft text-green-ink' : 'bg-amber-soft text-amber-ink' }}">
@if($passed)
<x-heroicon-s-trophy class="w-8 h-8" />
@else
<x-heroicon-s-arrow-path class="w-8 h-8" />
@endif
</div>
<div class="text-xl font-bold text-ink-1 mb-1">
@if($passed) Super gemacht! @else Fast geschafft @endif
</div>
<p class="text-sm text-ink-2 mb-2">
Du hast <span class="font-mono tabular-nums font-bold text-ink-1">{{ $correctAnswers }}</span>
von <span class="font-mono tabular-nums font-bold text-ink-1">{{ $totalSteps }}</span> Aufgaben richtig.
</p>
<p class="text-xs text-ink-3 mb-6 font-mono tabular-nums">{{ $percent }} % Erfolgsquote</p>
<div class="flex gap-2">
<x-btn wire:click="restart" class="flex-1 justify-center">
<x-heroicon-o-arrow-path class="w-4 h-4" />
Nochmal
</x-btn>
<x-btn variant="primary" wire:click="closeModal" class="flex-1 justify-center">
@if($passed) Punkte holen @else Fertig @endif
</x-btn>
</div>
</div>
@endif
</div>

View File

@ -1,16 +1,17 @@
@php
$colorMap = [
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink', 'bar' => 'bg-primary-ink', 'ring' => 'focus-visible:ring-primary'],
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink', 'bar' => 'bg-rose-ink', 'ring' => 'focus-visible:ring-rose-ink'],
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink', 'bar' => 'bg-violet-ink', 'ring' => 'focus-visible:ring-violet-ink'],
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink', 'bar' => 'bg-green-ink', 'ring' => 'focus-visible:ring-green-ink'],
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink', 'bar' => 'bg-amber-ink', 'ring' => 'focus-visible:ring-amber-ink'],
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink', 'bar' => 'bg-primary-ink', 'ring' => 'focus-visible:ring-primary', 'btn' => 'bg-primary-ink'],
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink', 'bar' => 'bg-rose-ink', 'ring' => 'focus-visible:ring-rose-ink', 'btn' => 'bg-rose-ink'],
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink', 'bar' => 'bg-violet-ink', 'ring' => 'focus-visible:ring-violet-ink','btn' => 'bg-violet-ink'],
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink', 'bar' => 'bg-green-ink', 'ring' => 'focus-visible:ring-green-ink', 'btn' => 'bg-green-ink'],
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink', 'bar' => 'bg-amber-ink', 'ring' => 'focus-visible:ring-amber-ink', 'btn' => 'bg-amber-ink'],
];
$c = $colorMap[$task['color']] ?? $colorMap['primary'];
$progressPercent = $totalSteps > 0 ? (int) round(($currentStep / $totalSteps) * 100) : 0;
$q = $finished ? null : $this->currentQuestion();
@endphp
<div class="flex flex-col gap-6 max-w-3xl mx-auto w-full" data-testid="task-run-page">
<div class="flex flex-col gap-6 max-w-6xl mx-auto w-full" data-testid="task-run-page">
{{-- Breadcrumb / back nav --}}
<div class="flex items-center justify-between">
@ -18,7 +19,10 @@
<x-heroicon-o-arrow-left class="w-4 h-4" />
Zurück zu Aufgaben
</a>
<x-pill>{{ $task['meta'] ?? '' }}</x-pill>
<div class="flex items-center gap-2">
<x-pill>{{ $task['subject'] ?? '' }}</x-pill>
<x-pill variant="primary">{{ $task['topic'] ?? '' }}</x-pill>
</div>
</div>
{{-- Task header --}}
@ -28,7 +32,7 @@
</div>
<div class="flex-1 min-w-0">
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold">{{ $task['meta'] }}</div>
<h1 class="font-bold text-xl text-ink-1 leading-tight truncate">{{ $task['title'] }}</h1>
<h1 class="font-bold text-xl text-ink-1 leading-tight truncate" data-testid="run-title">{{ $task['title'] }}</h1>
</div>
<div class="text-right shrink-0">
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold">Belohnung</div>
@ -52,60 +56,124 @@
</div>
</div>
{{-- Question --}}
@php $q = $this->currentQuestion(); @endphp
<div class="bg-bg-soft border border-line rounded-xl p-8 shadow-sm" wire:key="step-{{ $currentStep }}">
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-3">Aufgabe</div>
<h2 class="text-2xl font-bold tracking-tight text-ink-1 mb-8 leading-snug" data-testid="run-question">
{{ $q['question'] }}
</h2>
{{-- Workbook: Question left (2 cols), Lerni AI helper right --}}
<div class="grid grid-cols-3 gap-5">
<div class="grid grid-cols-2 gap-3">
@foreach($q['options'] as $i => $opt)
@php
$isLast = $lastAnswerIndex === $i;
$cls = 'group px-5 py-5 rounded-lg border-2 text-ink-1 font-semibold text-base text-left transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ' . $c['ring'];
$cls .= $lastAnswerIndex === null
? ' bg-bg-soft border-line hover:border-primary hover:bg-primary-soft'
: ($isLast
? ($lastAnswerCorrect ? ' bg-green-soft border-green-ink' : ' bg-rose-soft border-rose-ink')
: ' bg-bg-soft border-line opacity-60');
@endphp
<button
type="button"
@if($lastAnswerIndex !== null) disabled @else wire:click="submitAnswer({{ $i }})" @endif
class="{{ $cls }}"
data-testid="run-option-{{ $i }}"
>
<span class="flex items-center gap-3">
<span class="w-7 h-7 rounded-full grid place-items-center text-xs font-bold font-mono tabular-nums shrink-0
{{ $isLast ? ($lastAnswerCorrect ? 'bg-green-ink text-white' : 'bg-rose-ink text-white') : 'bg-bg-tint text-ink-2' }}">
{{ chr(65 + $i) }}
{{-- Question card (col-span 2) --}}
<div class="col-span-2 bg-bg-soft border border-line rounded-xl p-8 shadow-sm" wire:key="step-{{ $currentStep }}">
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-3">Aufgabe</div>
<h2 class="text-2xl font-bold tracking-tight text-ink-1 mb-8 leading-snug" data-testid="run-question">
{{ $q['question'] }}
</h2>
<div class="grid grid-cols-2 gap-3">
@foreach($q['options'] as $i => $opt)
@php
$isLast = $lastAnswerIndex === $i;
$cls = 'group px-5 py-5 rounded-lg border-2 text-ink-1 font-semibold text-base text-left transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ' . $c['ring'];
$cls .= $lastAnswerIndex === null
? ' bg-bg-soft border-line hover:border-primary hover:bg-primary-soft'
: ($isLast
? ($lastAnswerCorrect ? ' bg-green-soft border-green-ink' : ' bg-rose-soft border-rose-ink')
: ' bg-bg-soft border-line opacity-60');
@endphp
<button
type="button"
@if($lastAnswerIndex !== null) disabled @else wire:click="submitAnswer({{ $i }})" @endif
class="{{ $cls }}"
data-testid="run-option-{{ $i }}"
>
<span class="flex items-center gap-3">
<span class="w-7 h-7 rounded-full grid place-items-center text-xs font-bold font-mono tabular-nums shrink-0
{{ $isLast ? ($lastAnswerCorrect ? 'bg-green-ink text-white' : 'bg-rose-ink text-white') : 'bg-bg-tint text-ink-2' }}">
{{ chr(65 + $i) }}
</span>
<span>{{ $opt }}</span>
@if($isLast && $lastAnswerCorrect)
<x-heroicon-s-check-circle class="w-5 h-5 text-green-ink ml-auto shrink-0" />
@elseif($isLast)
<x-heroicon-s-x-circle class="w-5 h-5 text-rose-ink ml-auto shrink-0" />
@endif
</span>
<span>{{ $opt }}</span>
@if($isLast && $lastAnswerCorrect)
<x-heroicon-s-check-circle class="w-5 h-5 text-green-ink ml-auto shrink-0" />
@elseif($isLast)
<x-heroicon-s-x-circle class="w-5 h-5 text-rose-ink ml-auto shrink-0" />
</button>
@endforeach
</div>
@if($lastAnswerIndex !== null)
<div class="mt-6 flex justify-end">
<x-btn variant="primary" wire:click="nextStep" data-testid="run-next">
@if($currentStep < $totalSteps)
Nächste Frage
<x-heroicon-o-arrow-right class="w-4 h-4" />
@else
Aufgabe abschließen
<x-heroicon-o-check class="w-4 h-4" />
@endif
</span>
</button>
@endforeach
</x-btn>
</div>
@endif
</div>
@if($lastAnswerIndex !== null)
<div class="mt-6 flex justify-end">
<x-btn variant="primary" wire:click="nextStep" data-testid="run-next">
@if($currentStep < $totalSteps)
Nächste Frage
<x-heroicon-o-arrow-right class="w-4 h-4" />
@else
Aufgabe abschließen
<x-heroicon-o-check class="w-4 h-4" />
{{-- Lerni AI-Helper sidebar --}}
<aside class="bg-bg-soft border border-line rounded-xl shadow-sm overflow-hidden" data-testid="lerni-panel">
<header class="px-5 py-4 border-b border-line bg-bg-tint">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-primary-ink to-violet-ink text-white grid place-items-center font-bold shrink-0">
<x-heroicon-s-sparkles class="w-5 h-5" />
</div>
<div class="min-w-0">
<div class="font-bold text-sm text-ink-1">Lerni</div>
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold">KI-Helfer</div>
</div>
</div>
</header>
@if(! $showLerni)
<div class="p-5 text-center">
<p class="text-sm text-ink-2 mb-4 leading-relaxed">
Steckst du fest? Ich erkläre dir Schritt für Schritt, wie du diese Aufgabe lösen kannst.
</p>
<x-btn variant="primary" wire:click="toggleLerni" class="w-full justify-center" data-testid="lerni-open">
<x-heroicon-o-sparkles class="w-4 h-4" />
Frag Lerni
</x-btn>
</div>
@else
<div class="p-5 flex flex-col gap-4">
<div class="bg-primary-soft border border-primary-soft rounded-lg p-4">
<div class="text-[10px] uppercase tracking-wider text-primary-ink font-bold mb-1">💡 Tipp</div>
<p class="text-sm text-ink-1 leading-relaxed" data-testid="lerni-hint">{{ $q['lerni_hint'] ?? 'Versuch es mit dem Lösungsweg unten.' }}</p>
</div>
@if(! empty($q['lerni_steps']))
<div>
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-bold mb-2">Lösungsweg</div>
<ol class="flex flex-col gap-2" data-testid="lerni-steps">
@foreach($q['lerni_steps'] as $i => $step)
<li class="flex gap-2.5 items-start">
<span class="w-5 h-5 rounded-full {{ $c['btn'] }} text-white grid place-items-center text-[10px] font-bold font-mono shrink-0 mt-0.5">{{ $i + 1 }}</span>
<span class="text-sm text-ink-1 leading-snug">{{ $step }}</span>
</li>
@endforeach
</ol>
</div>
@endif
</x-btn>
</div>
@endif
<button type="button" wire:click="toggleLerni"
class="text-xs text-ink-3 hover:text-ink-1 underline self-start transition-colors"
data-testid="lerni-close">
Lerni schließen
</button>
{{-- Shortlink share (for parents/teachers) --}}
<div class="mt-2 pt-3 border-t border-line">
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-bold mb-1.5">Shortlink</div>
<code class="font-mono text-[11px] text-ink-2 break-all" data-testid="lerni-shortlink">{{ route('tasks.run', ['slug' => $slug]) }}?lerni=1</code>
</div>
</div>
@endif
</aside>
</div>
@else
@php
@ -113,7 +181,7 @@
$percent = $totalSteps > 0 ? (int) round(($correctAnswers / $totalSteps) * 100) : 0;
$pointsEarned = $passed ? (int) $task['points'] : 0;
@endphp
<div class="bg-bg-soft border border-line rounded-xl p-10 shadow-sm text-center" data-testid="run-finished">
<div class="bg-bg-soft border border-line rounded-xl p-10 shadow-sm text-center max-w-2xl mx-auto" data-testid="run-finished">
<div class="w-20 h-20 rounded-full grid place-items-center mx-auto mb-5 {{ $passed ? 'bg-green-soft text-green-ink' : 'bg-amber-soft text-amber-ink' }}">
@if($passed)
<x-heroicon-s-trophy class="w-10 h-10" />

View File

@ -13,8 +13,8 @@ Route::middleware(['auth', 'license'])->group(function () {
Route::get('/dashboard', \App\Livewire\Dashboard\Index::class)->name('dashboard');
Route::get('/subjects', \App\Livewire\Subjects\Index::class)->name('subjects');
Route::get('/tasks', \App\Livewire\Tasks\Index::class)->name('tasks');
Route::get('/tasks/{key}/run', \App\Livewire\Tasks\Run::class)
->whereAlphaNumeric('key')
Route::get('/tasks/{slug}/run', \App\Livewire\Tasks\Run::class)
->where('slug', '[a-z0-9-]+')
->name('tasks.run');
Route::get('/photo', \App\Livewire\PhotoHelp\Index::class)->name('photo');
Route::get('/rewards', \App\Livewire\Rewards\Index::class)->name('rewards');

View File

@ -4,28 +4,44 @@ it('task-questions.php config exists', function () {
expect(file_exists(base_path('config/task-questions.php')))->toBeTrue();
});
it('config has buckets for all 5 subject colors', function () {
it('config is keyed by task slug (semantic, not by color)', function () {
$bank = config('task-questions');
// Should NOT have keys 'primary', 'rose', etc. directly
foreach (['primary', 'rose', 'violet', 'green', 'amber'] as $color) {
expect(array_key_exists($color, $bank))->toBeTrue("Question bank missing color '{$color}'");
expect($bank[$color])->toBeArray()->not->toBeEmpty();
expect(array_key_exists($color, $bank))->toBeFalse("Bank should be slug-keyed, not color-keyed; found '{$color}'");
}
// Should have task slugs
expect(array_key_exists('math-multiplikation-bis-100', $bank))->toBeTrue();
expect(array_key_exists('deutsch-wortarten-nomen-verben', $bank))->toBeTrue();
});
it('each question has required keys (question, options, correct)', function () {
it('each question has required keys (question, options, correct, lerni_hint, lerni_steps)', function () {
$bank = config('task-questions');
foreach ($bank as $color => $questions) {
foreach ($bank as $slug => $questions) {
foreach ($questions as $i => $q) {
expect($q)->toHaveKeys(['question', 'options', 'correct']);
expect($q)->toHaveKeys(['question', 'options', 'correct', 'lerni_hint', 'lerni_steps']);
expect($q['options'])->toHaveCount(4);
expect($q['correct'])->toBeInt()->toBeGreaterThanOrEqual(0)->toBeLessThan(4);
expect($q['lerni_steps'])->toBeArray()->not->toBeEmpty();
}
}
});
it('Math (primary) questions differ from German (rose)', function () {
$bank = config('task-questions');
$mathFirst = $bank['primary'][0]['question'];
$germanFirst = $bank['rose'][0]['question'];
expect($mathFirst)->not->toBe($germanFirst);
it('each task slug in config/tasks.php has a matching question bank', function () {
foreach (config('tasks', []) as $t) {
$bank = config("task-questions.{$t['slug']}");
expect($bank)->toBeArray()->not->toBeEmpty("Task slug '{$t['slug']}' has no question bank");
}
});
it('different task slugs return different questions', function () {
$math = config('task-questions.math-multiplikation-bis-100');
$german = config('task-questions.deutsch-wortarten-nomen-verben');
expect($math[0]['question'])->not->toBe($german[0]['question']);
});
it('math-geometrie and math-multiplikation share color but have different questions', function () {
$mult = config('task-questions.math-multiplikation-bis-100');
$geom = config('task-questions.math-geometrie-flaechen');
expect($mult[0]['question'])->not->toBe($geom[0]['question']);
});

View File

@ -31,96 +31,146 @@ beforeEach(function () {
$this->actingAs($this->user);
});
it('route tasks.run registered with key param', function () {
it('route tasks.run registered with slug param', function () {
expect(\Illuminate\Support\Facades\Route::has('tasks.run'))->toBeTrue();
});
it('GET /tasks/t1/run returns 200 for authenticated user', function () {
$this->get(route('tasks.run', ['key' => 't1']))->assertOk();
it('GET /tasks/{slug}/run returns 200 for valid slug', function () {
$this->get(route('tasks.run', ['slug' => 'math-multiplikation-bis-100']))->assertOk();
});
it('GET /tasks/{key}/run with unknown key returns 404', function () {
$this->get(route('tasks.run', ['key' => 'nonexistent']))->assertNotFound();
it('GET /tasks/{slug}/run with unknown slug returns 404', function () {
$this->get(route('tasks.run', ['slug' => 'nonexistent-slug']))->assertNotFound();
});
it('Run mount: math task (t1, primary) loads MATH questions', function () {
$component = Livewire::test(Run::class, ['key' => 't1']);
$component->assertSet('totalSteps', 5); // primary bank has 5 questions
it('GET /tasks/{slug}/run with bogus URL chars returns 404', function () {
// Route regex [a-z0-9-]+ filters injection attempts
$this->get('/tasks/UPPER_CASE/run')->assertNotFound();
});
it('Run mount: math slug loads MATH questions (not generic)', function () {
$component = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']);
$component->assertSet('totalSteps', 5);
$component->assertSee('Multiplikation bis 100');
$component->assertSee('Was ergibt 12 × 8?');
$component->assertDontSee('Welches Wort ist ein Nomen?');
});
it('Run mount: deutsch task (t2, rose) loads GERMAN questions', function () {
$component = Livewire::test(Run::class, ['key' => 't2']);
it('Run mount: deutsch slug loads GERMAN questions', function () {
$component = Livewire::test(Run::class, ['slug' => 'deutsch-wortarten-nomen-verben']);
$component->assertSee('Welches Wort ist ein Nomen?');
$component->assertDontSee('Was ergibt 12 × 8?');
});
it('Run mount: english task (t3, violet) loads ENGLISH questions', function () {
$component = Livewire::test(Run::class, ['key' => 't3']);
$component->assertSee('past tense of "go"');
$component->assertDontSee('Welches Wort ist ein Nomen?');
it('Run mount: english vocab loads ENGLISH questions', function () {
$component = Livewire::test(Run::class, ['slug' => 'englisch-vocab-animals']);
$component->assertSee('Translate "Katze"');
});
it('Run mount: sachkunde task (t4, green) loads NATURE questions', function () {
$component = Livewire::test(Run::class, ['key' => 't4']);
it('Run mount: sachkunde wasserkreislauf loads NATURE questions', function () {
$component = Livewire::test(Run::class, ['slug' => 'sachkunde-wasserkreislauf']);
$component->assertSee('Verdampfen von Wasser');
});
it('math multiplikation task shows multiplication question', function () {
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
->assertSee('Was ergibt 12');
});
it('math geometry task shows geometry question (not multiplikation)', function () {
Livewire::withoutLazyLoading();
Livewire::test(Run::class, ['slug' => 'math-geometrie-flaechen'])
->assertSee('Rechtecks')
->assertDontSee('Was ergibt 12');
});
it('submitAnswer marks correct/incorrect + correctAnswers counter', function () {
Livewire::test(Run::class, ['key' => 't1'])
->call('submitAnswer', 2) // primary q1: correct = 2 (96)
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
->call('submitAnswer', 2) // q1 correct = index 2 (96)
->assertSet('lastAnswerIndex', 2)
->assertSet('lastAnswerCorrect', true)
->assertSet('correctAnswers', 1);
});
it('submitAnswer with wrong index sets lastAnswerCorrect=false, no point', function () {
Livewire::test(Run::class, ['key' => 't1'])
it('submitAnswer wrong index: no point', function () {
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
->call('submitAnswer', 0)
->assertSet('lastAnswerCorrect', false)
->assertSet('correctAnswers', 0);
});
it('nextStep advances and clears lastAnswer state', function () {
Livewire::test(Run::class, ['key' => 't1'])
it('nextStep advances + clears feedback + closes Lerni helper', function () {
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
->call('toggleLerni')
->assertSet('showLerni', true)
->call('submitAnswer', 2)
->call('nextStep')
->assertSet('currentStep', 2)
->assertSet('lastAnswerIndex', null)
->assertSet('lastAnswerCorrect', null);
->assertSet('showLerni', false);
});
it('finishing last step sets finished=true', function () {
$run = Livewire::test(Run::class, ['key' => 't1']);
// primary bank has 5 questions
it('finishing all 5 steps sets finished=true', function () {
$run = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100']);
foreach (range(1, 5) as $step) {
$run->call('submitAnswer', 0)->call('nextStep');
}
$run->assertSet('finished', true);
});
it('restart resets all progress', function () {
Livewire::test(Run::class, ['key' => 't1'])
it('restart resets all progress including Lerni state', function () {
Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
->call('toggleLerni')
->call('submitAnswer', 2)
->call('nextStep')
->call('restart')
->assertSet('currentStep', 1)
->assertSet('correctAnswers', 0)
->assertSet('finished', false)
->assertSet('lastAnswerIndex', null);
->assertSet('lastAnswerIndex', null)
->assertSet('showLerni', false);
});
it('startTask in Index redirects to tasks.run route (not modal)', function () {
it('startTask in Index redirects to tasks.run with slug', function () {
Livewire::test(\App\Livewire\Tasks\Index::class)
->call('startTask', 't1')
->assertRedirect(route('tasks.run', ['key' => 't1']));
->call('startTask', 'math-multiplikation-bis-100')
->assertRedirect(route('tasks.run', ['slug' => 'math-multiplikation-bis-100']));
});
it('unauthenticated user redirected to login', function () {
auth()->logout();
$this->get(route('tasks.run', ['key' => 't1']))->assertRedirect(route('login'));
$this->get(route('tasks.run', ['slug' => 'math-multiplikation-bis-100']))->assertRedirect(route('login'));
});
it('Lerni panel renders in HTML', function () {
$html = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])->html();
expect($html)->toContain('data-testid="lerni-panel"');
expect($html)->toContain('Lerni');
expect($html)->toContain('data-testid="lerni-open"');
});
it('toggleLerni reveals lerni-hint + lerni-steps', function () {
$html = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
->call('toggleLerni')
->html();
expect($html)->toContain('data-testid="lerni-hint"');
expect($html)->toContain('data-testid="lerni-steps"');
expect($html)->toContain('Zerlege 12 in 10 + 2');
});
it('Lerni shortlink URL contains slug + ?lerni=1', function () {
$html = Livewire::test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
->call('toggleLerni')
->html();
expect($html)->toContain('data-testid="lerni-shortlink"');
expect($html)->toContain('math-multiplikation-bis-100');
expect($html)->toContain('?lerni=1');
});
it('?lerni=1 query auto-opens helper on first paint', function () {
Livewire::withQueryParams(['lerni' => 1])
->test(Run::class, ['slug' => 'math-multiplikation-bis-100'])
->assertSet('showLerni', true);
});
it('Run placeholder view exists with task-run-skeleton testid', function () {
@ -128,3 +178,29 @@ it('Run placeholder view exists with task-run-skeleton testid', function () {
expect($view->getName())->toBe('livewire.tasks.run-placeholder');
expect($view->render())->toContain('task-run-skeleton');
});
it('config/tasks.php uses semantic slugs (not t1/t2)', function () {
$tasks = config('tasks');
foreach ($tasks as $t) {
expect($t)->toHaveKey('slug');
expect($t['slug'])->toMatch('/^[a-z0-9-]+$/', "Slug '{$t['slug']}' must be lowercase + hyphens");
expect($t['slug'])->not->toMatch('/^t[0-9]+$/', "Slug '{$t['slug']}' is generic — use semantic name");
}
});
it('every task slug has a matching question bank entry', function () {
foreach (config('tasks', []) as $t) {
$questions = config("task-questions.{$t['slug']}");
expect($questions)->toBeArray()->not->toBeEmpty("Slug '{$t['slug']}' missing questions");
}
});
it('each question has lerni_hint + lerni_steps for AI helper', function () {
foreach (config('task-questions', []) as $slug => $questions) {
foreach ($questions as $i => $q) {
expect($q)->toHaveKey('lerni_hint');
expect($q)->toHaveKey('lerni_steps');
expect($q['lerni_steps'])->toBeArray()->not->toBeEmpty();
}
}
});

View File

@ -1,109 +0,0 @@
<?php
use App\Livewire\Tasks\Modals\Runner;
use App\Models\License;
use App\Models\Role;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use LivewireUI\Modal\ModalComponent;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
$this->seed(\Database\Seeders\RolesSeeder::class);
$tenant = Tenant::firstOrCreate(['name' => 'Test Schule'], ['type' => 'school', 'active' => true]);
$this->user = User::withoutGlobalScopes()->create([
'name' => 'Runner Tester', 'email' => 'runner@test.example',
'tenant_id' => $tenant->id, 'password' => bcrypt('password'),
]);
$role = Role::where('name', 'child')->first();
DB::table('role_user')->insert(['user_id' => $this->user->id, 'role_id' => $role->id, 'created_at' => now(), 'updated_at' => now()]);
$license = License::withoutGlobalScopes()->create([
'tenant_id' => $tenant->id, 'type' => 'school_flat',
'expires_at' => now()->addYear(), 'active' => true,
]);
DB::table('license_assignments')->insert(['license_id' => $license->id, 'user_id' => $this->user->id, 'created_at' => now(), 'updated_at' => now()]);
$this->task = ['key' => 't1', 'title' => 'Multiplikation bis 100', 'meta' => 'Mathe · 4 Aufgaben', 'icon' => '∑', 'points' => 120];
$this->actingAs($this->user);
});
it('Runner modal class extends ModalComponent', function () {
expect(is_subclass_of(Runner::class, ModalComponent::class))->toBeTrue();
});
it('Runner alias resolves to App\\Livewire\\Tasks\\Modals\\Runner', function () {
$cls = app('livewire.finder')->resolveClassComponentClassName('tasks.modals.runner');
expect($cls)->toBe(Runner::class);
});
it('Runner renders question + 4 options for step 1', function () {
Livewire::test(Runner::class, ['task' => $this->task])
->assertSet('currentStep', 1)
->assertSet('totalSteps', 4)
->assertSet('finished', false)
->assertSee('Multiplikation bis 100')
->assertSeeHtml('data-testid="runner-question"')
->assertSeeHtml('data-testid="runner-option-0"')
->assertSeeHtml('data-testid="runner-option-3"');
});
it('submitAnswer with correct option increments correctAnswers + advances step', function () {
Livewire::test(Runner::class, ['task' => $this->task])
->call('submitAnswer', 2) // step 1 correct = index 2
->assertSet('correctAnswers', 1)
->assertSet('currentStep', 2);
});
it('submitAnswer with wrong option still advances step but no point', function () {
Livewire::test(Runner::class, ['task' => $this->task])
->call('submitAnswer', 0) // step 1 wrong
->assertSet('correctAnswers', 0)
->assertSet('currentStep', 2);
});
it('finishing all steps sets finished=true', function () {
Livewire::test(Runner::class, ['task' => $this->task])
->call('submitAnswer', 2) // step 1
->call('submitAnswer', 1) // step 2
->call('submitAnswer', 1) // step 3
->call('submitAnswer', 2) // step 4 — final
->assertSet('finished', true)
->assertSet('correctAnswers', 4)
->assertSeeHtml('data-testid="runner-finished"');
});
it('restart resets state', function () {
Livewire::test(Runner::class, ['task' => $this->task])
->call('submitAnswer', 2)
->call('submitAnswer', 1)
->call('restart')
->assertSet('currentStep', 1)
->assertSet('correctAnswers', 0)
->assertSet('finished', false);
});
it('Runner does NOT close on click-away (must finish or cancel explicitly)', function () {
expect(Runner::closeModalOnClickAway())->toBeFalse();
});
it('Runner displays progress bar with step indicator', function () {
$html = Livewire::test(Runner::class, ['task' => $this->task])->html();
expect($html)->toContain('Frage 1 / 4');
});
it('Runner shows trophy on success, arrow-path on partial', function () {
// 4/4 correct → trophy (passed)
$html = Livewire::test(Runner::class, ['task' => $this->task])
->call('submitAnswer', 2)
->call('submitAnswer', 1)
->call('submitAnswer', 1)
->call('submitAnswer', 2)
->html();
expect($html)->toContain('Super gemacht!');
});

View File

@ -13,88 +13,78 @@ uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
$this->seed(\Database\Seeders\RolesSeeder::class);
$tenant = Tenant::firstOrCreate(
['name' => 'Test Schule'],
['type' => 'school', 'active' => true]
);
$tenant = Tenant::firstOrCreate(['name' => 'Test Schule'], ['type' => 'school', 'active' => true]);
$this->user = User::withoutGlobalScopes()->create([
'name' => 'Start Tester',
'email' => 'start@test.example',
'tenant_id' => $tenant->id,
'password' => bcrypt('password'),
'name' => 'Start Tester', 'email' => 'start@test.example',
'tenant_id' => $tenant->id, 'password' => bcrypt('password'),
]);
$role = Role::where('name', 'child')->first();
DB::table('role_user')->insert([
'user_id' => $this->user->id,
'role_id' => $role->id,
'user_id' => $this->user->id, 'role_id' => $role->id,
'created_at' => now(), 'updated_at' => now(),
]);
$license = License::withoutGlobalScopes()->create([
'tenant_id' => $tenant->id,
'type' => 'school_flat',
'expires_at' => now()->addYear(),
'active' => true,
'tenant_id' => $tenant->id, 'type' => 'school_flat',
'expires_at' => now()->addYear(), 'active' => true,
]);
DB::table('license_assignments')->insert([
'license_id' => $license->id,
'user_id' => $this->user->id,
'license_id' => $license->id, 'user_id' => $this->user->id,
'created_at' => now(), 'updated_at' => now(),
]);
$this->actingAs($this->user);
});
it('Start button has data-testid + wire:click="startTask(...)"', function () {
it('Start button has data-testid + wire:click="startTask(<slug>)"', function () {
$html = Livewire::test(TasksIndex::class)->html();
expect($html)->toContain('data-testid="task-start-btn"');
expect($html)->toContain("wire:click=\"startTask('t1')\"");
expect($html)->toContain("wire:click=\"startTask('math-multiplikation-bis-100')\"");
});
it('Repeat button has wire:click="repeatTask(...)" for done tasks', function () {
it('Repeat button has wire:click="repeatTask(<slug>)" for done tasks', function () {
$html = Livewire::test(TasksIndex::class)->call('setFilter', 'done')->html();
expect($html)->toContain('data-testid="task-repeat-btn"');
expect($html)->toContain('repeatTask(');
});
it('startTask action runs and adds key to startedKeys', function () {
it('startTask action runs and adds slug to startedKeys', function () {
Livewire::test(TasksIndex::class)
->assertSet('startedKeys', [])
->call('startTask', 't1')
->assertSet('startedKeys', ['t1']);
->call('startTask', 'math-multiplikation-bis-100')
->assertSet('startedKeys', ['math-multiplikation-bis-100']);
});
it('startTask redirects to tasks.run page (dedicated quiz, not modal)', function () {
it('startTask redirects to tasks.run page with slug', function () {
Livewire::test(TasksIndex::class)
->call('startTask', 't1')
->assertRedirect(route('tasks.run', ['key' => 't1']));
->call('startTask', 'math-multiplikation-bis-100')
->assertRedirect(route('tasks.run', ['slug' => 'math-multiplikation-bis-100']));
});
it('Detail (info) button still opens tasks.modals.detail — separate from Start', function () {
$html = Livewire::test(TasksIndex::class)->html();
// Info-button is client-side wire:click="$dispatch('openModal', ...detail...)"
expect($html)->toContain('data-testid="task-detail-btn"');
expect($html)->toContain('tasks.modals.detail');
});
it('startTask ignores unknown task keys', function () {
it('startTask ignores unknown slug', function () {
Livewire::test(TasksIndex::class)
->call('startTask', 'non-existent')
->call('startTask', 'non-existent-slug')
->assertSet('startedKeys', []);
});
it('repeatTask works for done tasks', function () {
Livewire::test(TasksIndex::class)
->call('repeatTask', 't6')
->assertSet('startedKeys', ['t6']);
->call('repeatTask', 'deutsch-gedicht-lernen')
->assertSet('startedKeys', ['deutsch-gedicht-lernen']);
});
it('multiple startTask calls do not duplicate startedKeys', function () {
Livewire::test(TasksIndex::class)
->call('startTask', 't1')
->call('startTask', 't1')
->call('startTask', 't1')
->assertSet('startedKeys', ['t1']);
->call('startTask', 'math-multiplikation-bis-100')
->call('startTask', 'math-multiplikation-bis-100')
->call('startTask', 'math-multiplikation-bis-100')
->assertSet('startedKeys', ['math-multiplikation-bis-100']);
});