feat(quiz): new Quiz module — build & play private quizzes (Phase 1)

Four question types (single/multi/truefalse/text), inline editor,
play view with per-question feedback + final score review. Attempts
are persisted per quiz. Encrypted at rest: title/description/tags on
the container, questionText/explanation/options on questions.
Attempts stay plaintext. Dexie v21, appId 'quiz', tier 'guest'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 20:54:07 +02:00
parent 0af50f0166
commit 3b99356464
18 changed files with 1978 additions and 0 deletions

View file

@ -472,6 +472,24 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// Free-form user text — encrypt the content, leave the fixed id plaintext.
kontextDoc: { enabled: true, fields: ['content'] },
// ─── Quiz ────────────────────────────────────────────────
// User-typed text on the container (title, description, category, tags)
// plus the whole question payload (questionText, explanation, options).
// `options` is QuestionOption[] — aes.ts JSON-stringifies before wrap,
// same as food.foods / recipes.ingredients. The correctness flag inside
// each option ships encrypted alongside the text, which is intentional:
// a passive attacker with raw DB access should not be able to build an
// answer key without the user's vault.
// Plaintext (intentional): quizId foreign key, order, type discriminator,
// questionCount denorm counter, isPinned / isArchived — all needed for
// index/sort/filter.
quizzes: { enabled: true, fields: ['title', 'description', 'category', 'tags'] },
quizQuestions: { enabled: true, fields: ['questionText', 'explanation', 'options'] },
// `quizAttempts` is intentionally NOT registered — only boolean `correct`
// flags + a numeric score + timestamps + a small text-answer echo for
// review. If post-launch review shows textAnswer should be encrypted,
// add an entry here with fields: ['answers'].
// ─── AI Agents ───────────────────────────────────────────
// Named AI personas (docs/plans/multi-agent-workbench.md). `name` +
// `role` + `avatar` stay plaintext because `name` is the display-key

View file

@ -536,6 +536,18 @@ db.version(20).stores({
_aiDebugLog: 'iterationId, capturedAt',
});
// v21 — Quiz module. Three tables: container (`quizzes`), per-quiz items
// (`quizQuestions`, indexed on quizId for the play/edit view), and play-
// through history (`quizAttempts`, indexed on quizId + startedAt for the
// per-quiz leaderboard view). questionCount lives on `quizzes` as a
// denormalised counter so the list view doesn't fan out into per-quiz
// question scans.
db.version(21).stores({
quizzes: 'id, isPinned, isArchived, updatedAt',
quizQuestions: 'id, quizId, order, [quizId+order]',
quizAttempts: 'id, quizId, startedAt, [quizId+startedAt]',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -96,6 +96,7 @@ import { meditateModuleConfig } from '$lib/modules/meditate/module.config';
import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
import { moodModuleConfig } from '$lib/modules/mood/module.config';
import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
@ -148,6 +149,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
sleepModuleConfig,
moodModuleConfig,
kontextModuleConfig,
quizModuleConfig,
aiModuleConfig,
];

View file

@ -0,0 +1,583 @@
<!--
Quiz — EditView
Edit quiz meta + add / edit / reorder questions inline.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useQuiz, useQuestions, blankOption } from './queries';
import { quizzesStore } from './stores/quizzes.svelte';
import { QUESTION_TYPE_LABELS } from './types';
import type { QuestionType, QuestionOption, QuizQuestion } from './types';
import { ArrowLeft, Plus, Trash, Check, Play } from '@mana/shared-icons';
interface Props {
quizId: string;
}
let { quizId }: Props = $props();
const quiz$ = useQuiz(quizId);
const quiz = $derived(quiz$.value);
const questions$ = useQuestions(quizId);
const questions = $derived(questions$.value);
// ── Inline meta editing (debounced on blur) ─────────
let metaTitle = $state('');
let metaDescription = $state('');
let metaCategory = $state('');
let metaTags = $state('');
let metaLoaded = $state(false);
$effect(() => {
if (!quiz || metaLoaded) return;
metaTitle = quiz.title;
metaDescription = quiz.description ?? '';
metaCategory = quiz.category ?? '';
metaTags = (quiz.tags ?? []).join(', ');
metaLoaded = true;
});
async function saveMeta() {
if (!quiz) return;
await quizzesStore.updateQuiz(quiz.id, {
title: metaTitle.trim() || 'Unbenannt',
description: metaDescription.trim() || null,
category: metaCategory.trim() || null,
tags: metaTags
.split(',')
.map((s) => s.trim())
.filter(Boolean),
});
}
// ── New question form ───────────────────────────────
let newType = $state<QuestionType>('single');
let newText = $state('');
let newExplanation = $state('');
let newOptions = $state<QuestionOption[]>([
{ id: crypto.randomUUID(), text: '', isCorrect: true },
{ id: crypto.randomUUID(), text: '', isCorrect: false },
]);
let newTextAnswer = $state('');
function resetNewForm() {
newText = '';
newExplanation = '';
newTextAnswer = '';
if (newType === 'truefalse') {
newOptions = [
{ id: 't', text: 'Wahr', isCorrect: true },
{ id: 'f', text: 'Falsch', isCorrect: false },
];
} else if (newType === 'text') {
newOptions = [];
} else {
newOptions = [
{ id: crypto.randomUUID(), text: '', isCorrect: newType === 'single' },
{ id: crypto.randomUUID(), text: '', isCorrect: false },
];
}
}
$effect(() => {
// Rebuild option slots when question type changes.
newType;
resetNewForm();
});
function toggleNewCorrect(id: string) {
if (newType === 'single' || newType === 'truefalse') {
newOptions = newOptions.map((o) => ({ ...o, isCorrect: o.id === id }));
} else {
newOptions = newOptions.map((o) => (o.id === id ? { ...o, isCorrect: !o.isCorrect } : o));
}
}
function addNewOption() {
newOptions = [...newOptions, blankOption()];
}
function removeNewOption(id: string) {
newOptions = newOptions.filter((o) => o.id !== id);
}
async function submitNewQuestion() {
const text = newText.trim();
if (!text) return;
let options: QuestionOption[];
if (newType === 'text') {
const answer = newTextAnswer.trim();
if (!answer) return;
options = [{ id: 'answer', text: answer, isCorrect: true }];
} else {
const valid = newOptions.filter((o) => o.text.trim());
if (valid.length < 2) return;
if (!valid.some((o) => o.isCorrect)) return;
options = valid.map((o) => ({ ...o, text: o.text.trim() }));
}
await quizzesStore.addQuestion(quizId, {
type: newType,
questionText: text,
options,
explanation: newExplanation.trim() || null,
});
resetNewForm();
}
async function deleteQuestion(id: string) {
if (!confirm('Frage löschen?')) return;
await quizzesStore.deleteQuestion(id);
}
function correctLabel(q: QuizQuestion): string {
if (q.type === 'text') return q.options[0]?.text ?? '';
return q.options
.filter((o) => o.isCorrect)
.map((o) => o.text)
.join(', ');
}
</script>
<div class="wrap">
<header class="header">
<button class="back" onclick={() => goto('/quiz')} aria-label="Zurück">
<ArrowLeft size={18} /> Quiz
</button>
{#if quiz}
<button
class="play-btn"
disabled={questions.length === 0}
onclick={() => goto(`/quiz/${quiz.id}/play`)}
>
<Play size={14} weight="fill" /> Spielen
</button>
{/if}
</header>
{#if !quiz}
<p class="empty">Quiz nicht gefunden.</p>
{:else}
<section class="meta-section">
<input
class="title-input"
type="text"
bind:value={metaTitle}
onblur={saveMeta}
placeholder="Titel"
/>
<textarea
class="desc-input"
bind:value={metaDescription}
onblur={saveMeta}
placeholder="Beschreibung (optional)"
rows="2"
></textarea>
<div class="meta-row">
<input
class="small-input"
type="text"
bind:value={metaCategory}
onblur={saveMeta}
placeholder="Kategorie"
/>
<input
class="small-input"
type="text"
bind:value={metaTags}
onblur={saveMeta}
placeholder="Tags (Komma-getrennt)"
/>
</div>
</section>
<section class="questions-section">
<h2>Fragen ({questions.length})</h2>
{#if questions.length === 0}
<p class="empty">Noch keine Fragen — füge unten eine hinzu.</p>
{:else}
<ol class="question-list">
{#each questions as q, i (q.id)}
<li class="question-item">
<div class="q-header">
<span class="q-num">{i + 1}</span>
<span class="q-type">{QUESTION_TYPE_LABELS[q.type]}</span>
<button
class="icon-btn"
title="Löschen"
aria-label="Löschen"
onclick={() => deleteQuestion(q.id)}
>
<Trash size={14} />
</button>
</div>
<p class="q-text">{q.questionText}</p>
<p class="q-answer">
<Check size={12} /> <strong>{correctLabel(q)}</strong>
</p>
{#if q.explanation}
<p class="q-expl">{q.explanation}</p>
{/if}
</li>
{/each}
</ol>
{/if}
</section>
<section class="new-section">
<h2>Neue Frage</h2>
<label class="field">
<span>Typ</span>
<select bind:value={newType}>
<option value="single">Single Choice</option>
<option value="multi">Multiple Choice</option>
<option value="truefalse">Wahr / Falsch</option>
<option value="text">Texteingabe</option>
</select>
</label>
<label class="field">
<span>Frage</span>
<textarea bind:value={newText} rows="2" placeholder="Was möchtest du fragen?"></textarea>
</label>
{#if newType === 'text'}
<label class="field">
<span>Korrekte Antwort</span>
<input type="text" bind:value={newTextAnswer} placeholder="Erwartete Eingabe" />
</label>
{:else}
<div class="options-block">
<span class="options-label">
Antworten {newType === 'multi' ? '(mehrere richtig möglich)' : '(eine richtig)'}
</span>
{#each newOptions as opt, i (opt.id)}
<div class="option-row">
<button
class="correct-toggle"
class:on={opt.isCorrect}
title={opt.isCorrect ? 'Richtig' : 'Als richtig markieren'}
aria-label="Richtig"
onclick={() => toggleNewCorrect(opt.id)}
>
{#if opt.isCorrect}<Check size={14} weight="bold" />{/if}
</button>
<input
type="text"
bind:value={newOptions[i].text}
placeholder={`Antwort ${i + 1}`}
disabled={newType === 'truefalse'}
/>
{#if newType !== 'truefalse' && newOptions.length > 2}
<button
class="icon-btn"
aria-label="Entfernen"
onclick={() => removeNewOption(opt.id)}
>
<Trash size={14} />
</button>
{/if}
</div>
{/each}
{#if newType !== 'truefalse' && newOptions.length < 6}
<button class="add-option" onclick={addNewOption}>
<Plus size={12} /> Antwort hinzufügen
</button>
{/if}
</div>
{/if}
<label class="field">
<span>Erklärung (optional)</span>
<textarea
bind:value={newExplanation}
rows="2"
placeholder="Wird nach dem Antworten angezeigt"
></textarea>
</label>
<button class="submit-btn" onclick={submitNewQuestion}>
<Plus size={14} /> Frage hinzufügen
</button>
</section>
{/if}
</div>
<style>
.wrap {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem;
max-width: 48rem;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.8125rem;
}
.back:hover {
color: hsl(var(--color-foreground));
}
.play-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.play-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
h2 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 0.5rem;
}
.empty {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
/* ── Meta ── */
.meta-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
}
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
outline: none;
padding: 0.25rem 0;
}
.desc-input,
.small-input {
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
resize: vertical;
font-family: inherit;
}
.meta-row {
display: flex;
gap: 0.5rem;
}
.small-input {
flex: 1;
}
.desc-input:focus,
.small-input:focus,
.title-input:focus {
border-color: hsl(var(--color-primary));
}
/* ── Question list ── */
.question-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.question-item {
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
}
.q-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.q-num {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-primary));
}
.q-type {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
}
.icon-btn {
margin-left: auto;
padding: 0.25rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: inline-flex;
align-items: center;
}
.icon-btn:hover {
color: hsl(var(--color-error));
background: hsl(var(--color-surface-hover));
}
.q-text {
margin: 0.375rem 0 0.25rem;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.q-answer {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-primary));
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.q-expl {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
/* ── New question form ── */
.new-section {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px dashed hsl(var(--color-border));
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field > span {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.field input,
.field textarea,
.field select {
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
font-family: inherit;
}
.field input:focus,
.field textarea:focus,
.field select:focus {
border-color: hsl(var(--color-primary));
}
.options-block {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.options-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.option-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.option-row input {
flex: 1;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
}
.correct-toggle {
width: 22px;
height: 22px;
border-radius: 9999px;
border: 1.5px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-primary-foreground));
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.correct-toggle.on {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
}
.add-option {
align-self: flex-start;
background: transparent;
border: 1px dashed hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.add-option:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.submit-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,309 @@
<!--
Quiz — ListView
Browse, create, pin and launch quizzes.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import type { ViewProps } from '$lib/app-registry';
import { useAllQuizzes, searchQuizzes } from './queries';
import { quizzesStore } from './stores/quizzes.svelte';
import { PushPin, PencilSimple, Play, Trash } from '@mana/shared-icons';
let { navigate, goBack, params }: ViewProps = $props();
const quizzes$ = useAllQuizzes();
const quizzes = $derived(quizzes$.value);
let searchQuery = $state('');
let newTitle = $state('');
const filtered = $derived(searchQuizzes(quizzes, searchQuery));
async function handleCreate() {
const title = newTitle.trim();
if (!title) return;
const quiz = await quizzesStore.createQuiz({ title });
newTitle = '';
goto(`/quiz/${quiz.id}/edit`);
}
async function togglePin(id: string) {
await quizzesStore.togglePin(id);
}
async function handleDelete(id: string) {
if (!confirm('Quiz wirklich löschen?')) return;
await quizzesStore.deleteQuiz(id);
}
</script>
<svelte:head>
<title>Quiz - Mana</title>
</svelte:head>
<div class="wrap">
<header class="header">
<h1>Quiz</h1>
<input class="search-input" type="search" placeholder="Suchen…" bind:value={searchQuery} />
</header>
<form class="create" onsubmit={(e) => (e.preventDefault(), handleCreate())}>
<input
class="create-input"
type="text"
placeholder="Neues Quiz — Titel eingeben und Enter"
bind:value={newTitle}
/>
<button class="create-btn" type="submit" disabled={!newTitle.trim()}>Anlegen</button>
</form>
{#if filtered.length === 0}
<p class="empty">
{searchQuery ? 'Keine Quizze gefunden.' : 'Noch keine Quizze. Leg eins oben an.'}
</p>
{:else}
<ul class="quiz-list">
{#each filtered as quiz (quiz.id)}
<li class="quiz-item" class:pinned={quiz.isPinned}>
<div class="quiz-main">
<div class="quiz-top">
{#if quiz.isPinned}
<span class="pin-dot" aria-label="Angeheftet"></span>
{/if}
<span class="quiz-title">{quiz.title}</span>
<span class="count">{quiz.questionCount} Fragen</span>
</div>
{#if quiz.description}
<p class="quiz-desc">{quiz.description}</p>
{/if}
{#if quiz.tags.length > 0 || quiz.category}
<div class="meta">
{#if quiz.category}<span class="chip">{quiz.category}</span>{/if}
{#each quiz.tags as tag}<span class="chip chip-tag">#{tag}</span>{/each}
</div>
{/if}
</div>
<div class="actions">
<button
class="icon-btn"
title="Anheften"
aria-label="Anheften"
onclick={() => togglePin(quiz.id)}
>
<PushPin size={16} weight={quiz.isPinned ? 'fill' : 'regular'} />
</button>
<button
class="icon-btn"
title="Bearbeiten"
aria-label="Bearbeiten"
onclick={() => goto(`/quiz/${quiz.id}/edit`)}
>
<PencilSimple size={16} />
</button>
<button
class="icon-btn"
title="Löschen"
aria-label="Löschen"
onclick={() => handleDelete(quiz.id)}
>
<Trash size={16} />
</button>
<button
class="play-btn"
disabled={quiz.questionCount === 0}
onclick={() => goto(`/quiz/${quiz.id}/play`)}
>
<Play size={14} weight="fill" /> Spielen
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.wrap {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-width: 56rem;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.search-input {
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
}
.search-input:focus {
border-color: hsl(var(--color-primary));
}
.create {
display: flex;
gap: 0.5rem;
}
.create-input {
flex: 1;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
outline: none;
}
.create-input:focus {
border-color: hsl(var(--color-primary));
}
.create-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.create-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
text-align: center;
padding: 2rem 0;
}
.quiz-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.quiz-item {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
}
.quiz-item.pinned {
border-color: hsl(var(--color-primary) / 0.5);
}
.quiz-main {
flex: 1;
min-width: 0;
}
.quiz-top {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pin-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: hsl(var(--color-primary));
}
.quiz-title {
font-weight: 500;
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
}
.count {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-left: auto;
}
.quiz-desc {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.375rem;
}
.chip {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.chip-tag {
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-muted-foreground));
}
.actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.icon-btn {
padding: 0.375rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.play-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.play-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,460 @@
<!--
Quiz — PlayView
Walk through all questions, grade, then show result.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { useQuiz, useQuestions, evaluateAnswer } from './queries';
import { attemptsStore } from './stores/attempts.svelte';
import type { AttemptAnswer } from './types';
import { ArrowLeft, Check, X } from '@mana/shared-icons';
interface Props {
quizId: string;
}
let { quizId }: Props = $props();
const quiz$ = useQuiz(quizId);
const quiz = $derived(quiz$.value);
const questions$ = useQuestions(quizId);
const questions = $derived(questions$.value);
let attemptId = $state<string | null>(null);
let currentIndex = $state(0);
let selectedIds = $state<string[]>([]);
let textInput = $state('');
let revealed = $state(false);
let answers = $state<AttemptAnswer[]>([]);
let finished = $state(false);
// Start an attempt once the questions list resolves.
$effect(() => {
if (!attemptId && questions.length > 0) {
attemptsStore.startAttempt(quizId).then((a) => (attemptId = a.id));
}
});
const current = $derived(questions[currentIndex] ?? null);
const total = $derived(questions.length);
const correctCount = $derived(answers.filter((a) => a.correct).length);
const scorePct = $derived(
answers.length === 0 ? 0 : Math.round((correctCount / answers.length) * 100)
);
function toggleSelect(optId: string) {
if (revealed || !current) return;
if (current.type === 'multi') {
selectedIds = selectedIds.includes(optId)
? selectedIds.filter((x) => x !== optId)
: [...selectedIds, optId];
} else {
selectedIds = [optId];
}
}
function reveal() {
if (!current) return;
const correct = evaluateAnswer(
current,
selectedIds,
current.type === 'text' ? textInput : null
);
answers = [
...answers,
{
questionId: current.id,
selectedOptionIds: selectedIds,
textAnswer: current.type === 'text' ? textInput : null,
correct,
},
];
revealed = true;
}
function next() {
if (currentIndex + 1 >= total) {
finish();
return;
}
currentIndex += 1;
selectedIds = [];
textInput = '';
revealed = false;
}
async function finish() {
if (attemptId) {
await attemptsStore.finishAttempt(attemptId, answers);
}
finished = true;
}
function restart() {
answers = [];
currentIndex = 0;
selectedIds = [];
textInput = '';
revealed = false;
finished = false;
attemptId = null;
}
const canAnswer = $derived(
!!current && (current.type === 'text' ? textInput.trim().length > 0 : selectedIds.length > 0)
);
</script>
<div class="wrap">
<header class="header">
<button class="back" onclick={() => goto('/quiz')} aria-label="Zurück">
<ArrowLeft size={18} /> Quiz
</button>
{#if quiz}
<span class="title">{quiz.title}</span>
{/if}
{#if !finished && total > 0}
<span class="progress">{currentIndex + 1} / {total}</span>
{/if}
</header>
{#if !quiz}
<p class="empty">Quiz nicht gefunden.</p>
{:else if total === 0}
<p class="empty">Dieses Quiz hat noch keine Fragen.</p>
{:else if finished}
<section class="result">
<div class="score">
<span class="score-num">{scorePct}%</span>
<span class="score-sub">{correctCount} von {total} richtig</span>
</div>
<ol class="review">
{#each questions as q, i (q.id)}
{@const ans = answers[i]}
<li class="review-item" class:ok={ans?.correct} class:fail={ans && !ans.correct}>
<div class="review-head">
{#if ans?.correct}<Check size={14} weight="bold" />{:else}<X
size={14}
weight="bold"
/>{/if}
<span>{q.questionText}</span>
</div>
{#if q.type === 'text'}
<p class="review-line">
Deine Antwort: <strong>{ans?.textAnswer || '—'}</strong>
</p>
{#if !ans?.correct}
<p class="review-line">Richtig: <strong>{q.options[0]?.text}</strong></p>
{/if}
{:else}
<p class="review-line">
Richtig:
<strong>
{q.options
.filter((o) => o.isCorrect)
.map((o) => o.text)
.join(', ')}
</strong>
</p>
{/if}
</li>
{/each}
</ol>
<div class="result-actions">
<button class="secondary-btn" onclick={() => goto('/quiz')}>Zurück zur Liste</button>
<button class="primary-btn" onclick={restart}>Nochmal spielen</button>
</div>
</section>
{:else if current}
<section class="play">
<p class="q-text">{current.questionText}</p>
{#if current.type === 'text'}
<input
class="text-answer"
type="text"
bind:value={textInput}
disabled={revealed}
placeholder="Deine Antwort"
/>
{#if revealed}
<p class="feedback" class:ok={answers.at(-1)?.correct}>
{#if answers.at(-1)?.correct}
<Check size={14} weight="bold" /> Richtig!
{:else}
<X size={14} weight="bold" /> Richtige Antwort:
<strong>{current.options[0]?.text}</strong>
{/if}
</p>
{/if}
{:else}
<ul class="options">
{#each current.options as opt (opt.id)}
{@const isSelected = selectedIds.includes(opt.id)}
{@const isCorrect = opt.isCorrect}
<li>
<button
class="option"
class:selected={isSelected}
class:reveal-correct={revealed && isCorrect}
class:reveal-wrong={revealed && isSelected && !isCorrect}
disabled={revealed}
onclick={() => toggleSelect(opt.id)}
>
<span class="option-text">{opt.text}</span>
{#if revealed && isCorrect}
<Check size={16} weight="bold" />
{:else if revealed && isSelected && !isCorrect}
<X size={16} weight="bold" />
{/if}
</button>
</li>
{/each}
</ul>
{/if}
{#if revealed && current.explanation}
<p class="explanation">{current.explanation}</p>
{/if}
<div class="play-actions">
{#if !revealed}
<button class="primary-btn" disabled={!canAnswer} onclick={reveal}>
Antwort prüfen
</button>
{:else}
<button class="primary-btn" onclick={next}>
{currentIndex + 1 >= total ? 'Ergebnis ansehen' : 'Weiter'}
</button>
{/if}
</div>
</section>
{/if}
</div>
<style>
.wrap {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1rem;
max-width: 40rem;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
gap: 1rem;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 0.8125rem;
}
.back:hover {
color: hsl(var(--color-foreground));
}
.title {
font-weight: 500;
color: hsl(var(--color-foreground));
}
.progress {
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.empty {
color: hsl(var(--color-muted-foreground));
text-align: center;
padding: 2rem 0;
}
/* ── Play ── */
.play {
display: flex;
flex-direction: column;
gap: 1rem;
}
.q-text {
font-size: 1.0625rem;
color: hsl(var(--color-foreground));
line-height: 1.4;
margin: 0;
}
.options {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1.5px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
text-align: left;
cursor: pointer;
}
.option:hover:not(:disabled) {
border-color: hsl(var(--color-primary) / 0.5);
}
.option.selected {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
}
.option.reveal-correct {
border-color: hsl(var(--color-success, 142 76% 36%));
background: hsl(var(--color-success, 142 76% 36%) / 0.12);
}
.option.reveal-wrong {
border-color: hsl(var(--color-error));
background: hsl(var(--color-error) / 0.1);
}
.option:disabled {
cursor: default;
}
.text-answer {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1.5px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
outline: none;
}
.text-answer:focus {
border-color: hsl(var(--color-primary));
}
.feedback {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: hsl(var(--color-error));
margin: 0;
}
.feedback.ok {
color: hsl(var(--color-success, 142 76% 36%));
}
.explanation {
padding: 0.625rem 0.875rem;
border-radius: 0.375rem;
background: hsl(var(--color-muted) / 0.2);
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
margin: 0;
font-style: italic;
}
.play-actions {
display: flex;
justify-content: flex-end;
}
.primary-btn {
padding: 0.5rem 1.25rem;
border-radius: 0.375rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.primary-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.secondary-btn {
padding: 0.5rem 1.25rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
}
/* ── Result ── */
.result {
display: flex;
flex-direction: column;
gap: 1rem;
}
.score {
text-align: center;
padding: 1.25rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-border));
}
.score-num {
display: block;
font-size: 2.5rem;
font-weight: 700;
color: hsl(var(--color-primary));
}
.score-sub {
display: block;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
.review {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.review-item {
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
}
.review-item.ok {
border-color: hsl(var(--color-success, 142 76% 36%) / 0.4);
}
.review-item.fail {
border-color: hsl(var(--color-error) / 0.4);
}
.review-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.review-item.ok .review-head {
color: hsl(var(--color-success, 142 76% 36%));
}
.review-item.fail .review-head {
color: hsl(var(--color-error));
}
.review-line {
margin: 0.25rem 0 0;
padding-left: 1.375rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.result-actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,66 @@
/**
* Quiz module Dexie collection accessors and guest seed.
*/
import { db } from '$lib/data/database';
import type { LocalQuiz, LocalQuizQuestion, LocalQuizAttempt } from './types';
export const quizTable = db.table<LocalQuiz>('quizzes');
export const quizQuestionTable = db.table<LocalQuizQuestion>('quizQuestions');
export const quizAttemptTable = db.table<LocalQuizAttempt>('quizAttempts');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_QUIZ_ID = 'quiz-demo';
export const QUIZ_GUEST_SEED = {
quizzes: [
{
id: DEMO_QUIZ_ID,
title: 'Willkommen — Mini-Quiz',
description: 'Drei Fragen, um alle Typen auszuprobieren.',
category: 'Demo',
tags: ['Start'],
questionCount: 3,
isPinned: true,
isArchived: false,
},
] satisfies LocalQuiz[],
quizQuestions: [
{
id: 'quiz-demo-q1',
quizId: DEMO_QUIZ_ID,
order: 0,
type: 'single',
questionText: 'Welche Hauptstadt gehört zu Frankreich?',
options: [
{ id: 'a', text: 'Berlin', isCorrect: false },
{ id: 'b', text: 'Paris', isCorrect: true },
{ id: 'c', text: 'Madrid', isCorrect: false },
{ id: 'd', text: 'Rom', isCorrect: false },
],
explanation: 'Paris ist die Hauptstadt Frankreichs.',
},
{
id: 'quiz-demo-q2',
quizId: DEMO_QUIZ_ID,
order: 1,
type: 'truefalse',
questionText: 'Die Erde ist eine Scheibe.',
options: [
{ id: 't', text: 'Wahr', isCorrect: false },
{ id: 'f', text: 'Falsch', isCorrect: true },
],
explanation: null,
},
{
id: 'quiz-demo-q3',
quizId: DEMO_QUIZ_ID,
order: 2,
type: 'text',
questionText: 'Wie heißt dieses Ökosystem? (Tipp: vier Buchstaben)',
options: [{ id: 'answer', text: 'Mana', isCorrect: true }],
explanation: 'Groß-/Kleinschreibung wird ignoriert.',
},
] satisfies LocalQuizQuestion[],
};

View file

@ -0,0 +1,35 @@
/**
* Quiz module barrel exports.
*/
export { quizzesStore } from './stores/quizzes.svelte';
export { attemptsStore } from './stores/attempts.svelte';
export {
useAllQuizzes,
useQuiz,
useQuestions,
useAttempts,
toQuiz,
toQuestion,
toAttempt,
evaluateAnswer,
computeScore,
searchQuizzes,
blankOption,
} from './queries';
export { quizTable, quizQuestionTable, quizAttemptTable, QUIZ_GUEST_SEED } from './collections';
export { QUESTION_TYPE_LABELS } from './types';
export type {
LocalQuiz,
LocalQuizQuestion,
LocalQuizAttempt,
Quiz,
QuizQuestion,
QuizAttempt,
QuestionOption,
QuestionType,
AttemptAnswer,
} from './types';

View file

@ -0,0 +1,10 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const quizModuleConfig: ModuleConfig = {
appId: 'quiz',
tables: [
{ name: 'quizzes' },
{ name: 'quizQuestions', syncName: 'questions' },
{ name: 'quizAttempts', syncName: 'attempts' },
],
};

View file

@ -0,0 +1,159 @@
/**
* Quiz reactive queries, type converters, pure helpers.
*
* Content fields (title, description, questionText, explanation, options)
* are encrypted at rest. Queries decrypt the visible slice before mapping
* to public DTOs. Attempts stay plaintext (only scores + timestamps +
* AttemptAnswer payloads, which hold no user-typed content).
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
LocalQuiz,
LocalQuizQuestion,
LocalQuizAttempt,
Quiz,
QuizQuestion,
QuizAttempt,
QuestionOption,
AttemptAnswer,
} from './types';
// ─── Converters ────────────────────────────────────────────
export function toQuiz(local: LocalQuiz): Quiz {
return {
id: local.id,
title: local.title,
description: local.description,
category: local.category,
tags: local.tags ?? [],
questionCount: local.questionCount ?? 0,
isPinned: local.isPinned ?? false,
isArchived: local.isArchived ?? false,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toQuestion(local: LocalQuizQuestion): QuizQuestion {
return {
id: local.id,
quizId: local.quizId,
order: local.order,
type: local.type,
questionText: local.questionText,
options: local.options ?? [],
explanation: local.explanation,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toAttempt(local: LocalQuizAttempt): QuizAttempt {
return {
id: local.id,
quizId: local.quizId,
startedAt: local.startedAt,
finishedAt: local.finishedAt,
score: local.score ?? 0,
answers: local.answers ?? [],
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllQuizzes() {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalQuiz>('quizzes').toArray()).filter(
(q) => !q.deletedAt && !q.isArchived
);
const decrypted = await decryptRecords('quizzes', visible);
return decrypted.map(toQuiz).sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
return b.updatedAt.localeCompare(a.updatedAt);
});
}, [] as Quiz[]);
}
export function useQuiz(id: string) {
return useLiveQueryWithDefault(
async () => {
const local = await db.table<LocalQuiz>('quizzes').get(id);
if (!local || local.deletedAt) return null;
const [decrypted] = await decryptRecords('quizzes', [local]);
return decrypted ? toQuiz(decrypted) : null;
},
null as Quiz | null
);
}
export function useQuestions(quizId: string) {
return useLiveQueryWithDefault(async () => {
const visible = (
await db.table<LocalQuizQuestion>('quizQuestions').where('quizId').equals(quizId).toArray()
).filter((q) => !q.deletedAt);
const decrypted = await decryptRecords('quizQuestions', visible);
return decrypted.map(toQuestion).sort((a, b) => a.order - b.order);
}, [] as QuizQuestion[]);
}
export function useAttempts(quizId: string) {
return useLiveQueryWithDefault(async () => {
const visible = (
await db.table<LocalQuizAttempt>('quizAttempts').where('quizId').equals(quizId).toArray()
).filter((a) => !a.deletedAt);
return visible.map(toAttempt).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
}, [] as QuizAttempt[]);
}
// ─── Scoring ───────────────────────────────────────────────
/**
* Evaluate a single answer against a question. Pure drives both the live
* Play view and the post-hoc attempt summary.
*/
export function evaluateAnswer(
question: QuizQuestion,
selectedOptionIds: string[],
textAnswer: string | null
): boolean {
if (question.type === 'text') {
const expected = question.options[0]?.text?.trim().toLowerCase() ?? '';
const given = (textAnswer ?? '').trim().toLowerCase();
return expected.length > 0 && given === expected;
}
const correctIds = new Set(question.options.filter((o) => o.isCorrect).map((o) => o.id));
const selected = new Set(selectedOptionIds);
if (correctIds.size !== selected.size) return false;
for (const id of correctIds) if (!selected.has(id)) return false;
return true;
}
export function computeScore(answers: AttemptAnswer[]): number {
if (answers.length === 0) return 0;
const correct = answers.filter((a) => a.correct).length;
return correct / answers.length;
}
// ─── Helpers ───────────────────────────────────────────────
export function searchQuizzes(quizzes: Quiz[], query: string): Quiz[] {
if (!query.trim()) return quizzes;
const q = query.toLowerCase();
return quizzes.filter((quiz) => {
const hay = [quiz.title, quiz.description, quiz.category, ...(quiz.tags ?? [])]
.filter(Boolean)
.join(' ')
.toLowerCase();
return hay.includes(q);
});
}
export function blankOption(): QuestionOption {
return { id: crypto.randomUUID(), text: '', isCorrect: false };
}

View file

@ -0,0 +1,49 @@
/**
* Attempts mutation service for quiz play-throughs.
*
* Not encrypted: attempts carry only references + booleans + a score.
* No user-typed content leaks here (answer text on `text` questions IS
* user input, but it's compared at evaluate-time and then stored as the
* boolean `correct` + the raw string echo for review). We keep the raw
* textAnswer plaintext for now if review of incorrect attempts ever
* shows it should be encrypted, flip the registry entry.
*/
import { quizAttemptTable } from '../collections';
import { toAttempt } from '../queries';
import type { LocalQuizAttempt, AttemptAnswer, QuizAttempt } from '../types';
function now() {
return new Date().toISOString();
}
export const attemptsStore = {
async startAttempt(quizId: string): Promise<QuizAttempt> {
const newLocal: LocalQuizAttempt = {
id: crypto.randomUUID(),
quizId,
startedAt: now(),
finishedAt: null,
score: 0,
answers: [],
};
const snapshot = toAttempt(newLocal);
await quizAttemptTable.add(newLocal);
return snapshot;
},
async finishAttempt(id: string, answers: AttemptAnswer[]) {
const score =
answers.length === 0 ? 0 : answers.filter((a) => a.correct).length / answers.length;
await quizAttemptTable.update(id, {
answers,
score,
finishedAt: now(),
updatedAt: now(),
});
},
async deleteAttempt(id: string) {
await quizAttemptTable.update(id, { deletedAt: now(), updatedAt: now() });
},
};

View file

@ -0,0 +1,115 @@
/**
* Quizzes + Questions mutation-only service.
*
* Encrypted fields: title, description, category, tags on quizzes;
* questionText, explanation, options on quizQuestions.
*/
import { quizTable, quizQuestionTable } from '../collections';
import { toQuiz } from '../queries';
import { encryptRecord } from '$lib/data/crypto';
import type { LocalQuiz, LocalQuizQuestion, Quiz, QuestionOption, QuestionType } from '../types';
function now() {
return new Date().toISOString();
}
export const quizzesStore = {
async createQuiz(data: {
title: string;
description?: string | null;
category?: string | null;
tags?: string[];
}): Promise<Quiz> {
const newLocal: LocalQuiz = {
id: crypto.randomUUID(),
title: data.title,
description: data.description ?? null,
category: data.category ?? null,
tags: data.tags ?? [],
questionCount: 0,
isPinned: false,
isArchived: false,
};
const snapshot = toQuiz(newLocal);
await encryptRecord('quizzes', newLocal);
await quizTable.add(newLocal);
return snapshot;
},
async updateQuiz(
id: string,
data: Partial<
Pick<LocalQuiz, 'title' | 'description' | 'category' | 'tags' | 'isPinned' | 'isArchived'>
>
) {
const diff: Partial<LocalQuiz> = { ...data, updatedAt: now() };
await encryptRecord('quizzes', diff);
await quizTable.update(id, diff);
},
async deleteQuiz(id: string) {
await quizTable.update(id, { deletedAt: now(), updatedAt: now() });
const questions = await quizQuestionTable.where('quizId').equals(id).toArray();
await Promise.all(
questions.map((q) => quizQuestionTable.update(q.id, { deletedAt: now(), updatedAt: now() }))
);
},
async togglePin(id: string) {
const quiz = await quizTable.get(id);
if (!quiz) return;
await quizTable.update(id, { isPinned: !quiz.isPinned, updatedAt: now() });
},
// ── Questions ──────────────────────────────────────────
async addQuestion(
quizId: string,
data: {
type: QuestionType;
questionText: string;
options: QuestionOption[];
explanation?: string | null;
}
) {
const existing = await quizQuestionTable.where('quizId').equals(quizId).count();
const newLocal: LocalQuizQuestion = {
id: crypto.randomUUID(),
quizId,
order: existing,
type: data.type,
questionText: data.questionText,
options: data.options,
explanation: data.explanation ?? null,
};
await encryptRecord('quizQuestions', newLocal);
await quizQuestionTable.add(newLocal);
await this.recountQuestions(quizId);
},
async updateQuestion(
id: string,
data: Partial<
Pick<LocalQuizQuestion, 'type' | 'questionText' | 'options' | 'explanation' | 'order'>
>
) {
const diff: Partial<LocalQuizQuestion> = { ...data, updatedAt: now() };
await encryptRecord('quizQuestions', diff);
await quizQuestionTable.update(id, diff);
},
async deleteQuestion(id: string) {
const q = await quizQuestionTable.get(id);
if (!q) return;
await quizQuestionTable.update(id, { deletedAt: now(), updatedAt: now() });
await this.recountQuestions(q.quizId);
},
async recountQuestions(quizId: string) {
const live = (await quizQuestionTable.where('quizId').equals(quizId).toArray()).filter(
(q) => !q.deletedAt
);
await quizTable.update(quizId, { questionCount: live.length, updatedAt: now() });
},
};

View file

@ -0,0 +1,101 @@
/**
* Quiz module types.
*
* Three tables: `quizzes` (container), `quizQuestions` (per-quiz items),
* `quizAttempts` (one row per play-through with per-question results).
*/
import type { BaseRecord } from '@mana/local-store';
export type QuestionType = 'single' | 'multi' | 'truefalse' | 'text';
export interface QuestionOption {
id: string;
text: string;
isCorrect: boolean;
}
// ─── Local (Dexie) Records ─────────────────────────────────
export interface LocalQuiz extends BaseRecord {
title: string;
description: string | null;
category: string | null;
tags: string[];
questionCount: number;
isPinned: boolean;
isArchived: boolean;
}
export interface LocalQuizQuestion extends BaseRecord {
quizId: string;
order: number;
type: QuestionType;
questionText: string;
/** `single` / `multi` / `truefalse`: one entry per choice with isCorrect flag.
* `text`: single entry whose `text` is the expected answer (case-insensitive compare). */
options: QuestionOption[];
explanation: string | null;
}
export interface AttemptAnswer {
questionId: string;
selectedOptionIds: string[];
textAnswer: string | null;
correct: boolean;
}
export interface LocalQuizAttempt extends BaseRecord {
quizId: string;
startedAt: string;
finishedAt: string | null;
score: number; // 0..1
answers: AttemptAnswer[];
}
// ─── Public DTOs ───────────────────────────────────────────
export interface Quiz {
id: string;
title: string;
description: string | null;
category: string | null;
tags: string[];
questionCount: number;
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export interface QuizQuestion {
id: string;
quizId: string;
order: number;
type: QuestionType;
questionText: string;
options: QuestionOption[];
explanation: string | null;
createdAt: string;
updatedAt: string;
}
export interface QuizAttempt {
id: string;
quizId: string;
startedAt: string;
finishedAt: string | null;
score: number;
answers: AttemptAnswer[];
createdAt: string;
updatedAt: string;
}
// ─── Labels ────────────────────────────────────────────────
export const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
single: 'Single Choice',
multi: 'Multiple Choice',
truefalse: 'Wahr / Falsch',
text: 'Texteingabe',
};

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/quiz/ListView.svelte';
</script>
<svelte:head>
<title>Quiz - Mana</title>
</svelte:head>
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />

View file

@ -0,0 +1,14 @@
<script lang="ts">
import { page } from '$app/stores';
import EditView from '$lib/modules/quiz/EditView.svelte';
const quizId = $derived($page.params.id ?? '');
</script>
<svelte:head>
<title>Quiz bearbeiten - Mana</title>
</svelte:head>
{#key quizId}
<EditView {quizId} />
{/key}

View file

@ -0,0 +1,14 @@
<script lang="ts">
import { page } from '$app/stores';
import PlayView from '$lib/modules/quiz/PlayView.svelte';
const quizId = $derived($page.params.id ?? '');
</script>
<svelte:head>
<title>Quiz spielen - Mana</title>
</svelte:head>
{#key quizId}
<PlayView {quizId} />
{/key}

View file

@ -85,6 +85,11 @@ export const APP_ICONS = {
chat: svgToDataUrl(chatSvg),
presi: svgToDataUrl(presiSvg),
cards: svgToDataUrl(cardsSvg),
quiz: svgToDataUrl(
// Speech-bubble question mark with a small checkmark — quiz / answer.
// Pink→fuchsia gradient to stand apart from the purple Cards icon.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="qz" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ec4899"/><stop offset="100%" style="stop-color:#a21caf"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#qz)"/><path d="M22 34a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8v24a8 8 0 0 1-8 8H42l-12 10v-10h0a8 8 0 0 1-8-8V34z" fill="white"/><path d="M42 42c0-5 4-8 8-8s8 3 8 8c0 4-4 5-6 7-1 1-2 3-2 5" stroke="#a21caf" stroke-width="3.5" stroke-linecap="round" fill="none"/><circle cx="50" cy="60" r="2.4" fill="#a21caf"/><path d="M60 72l5 5 11-11" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
),
picture: svgToDataUrl(pictureSvg),
quotes: svgToDataUrl(quotesSvg),
wisekeep: svgToDataUrl(wisekeepSvg),

View file

@ -170,6 +170,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'quiz',
name: 'Quiz',
description: {
de: 'Wissen testen',
en: 'Test your knowledge',
},
longDescription: {
de: 'Eigene Quizze bauen und spielen — Single-, Multiple-Choice, Wahr/Falsch oder Freitext.',
en: 'Build and play your own quizzes — single/multiple choice, true/false, or free text.',
},
icon: APP_ICONS.quiz,
color: '#ec4899',
comingSoon: false,
status: 'beta',
requiredTier: 'guest',
},
{
id: 'picture',
name: 'ManaPicture',