diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 4bfc2d8d7..af39e0168 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -472,6 +472,24 @@ export const ENCRYPTION_REGISTRY: Record = { // 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 diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index b3cb65e43..c56593ade 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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 diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 6ad674ef7..269631e18 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -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, ]; diff --git a/apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte b/apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte new file mode 100644 index 000000000..c70580e5e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte @@ -0,0 +1,583 @@ + + + +
+
+ + {#if quiz} + + {/if} +
+ + {#if !quiz} +

Quiz nicht gefunden.

+ {:else} +
+ + +
+ + +
+
+ +
+

Fragen ({questions.length})

+ {#if questions.length === 0} +

Noch keine Fragen — füge unten eine hinzu.

+ {:else} +
    + {#each questions as q, i (q.id)} +
  1. +
    + {i + 1} + {QUESTION_TYPE_LABELS[q.type]} + +
    +

    {q.questionText}

    +

    + {correctLabel(q)} +

    + {#if q.explanation} +

    {q.explanation}

    + {/if} +
  2. + {/each} +
+ {/if} +
+ +
+

Neue Frage

+ + + + {#if newType === 'text'} + + {:else} +
+ + Antworten {newType === 'multi' ? '(mehrere richtig möglich)' : '(eine richtig)'} + + {#each newOptions as opt, i (opt.id)} +
+ + + {#if newType !== 'truefalse' && newOptions.length > 2} + + {/if} +
+ {/each} + {#if newType !== 'truefalse' && newOptions.length < 6} + + {/if} +
+ {/if} + + + + +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/quiz/ListView.svelte b/apps/mana/apps/web/src/lib/modules/quiz/ListView.svelte new file mode 100644 index 000000000..1c3fc06d1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/ListView.svelte @@ -0,0 +1,309 @@ + + + + + Quiz - Mana + + +
+
+

Quiz

+ +
+ +
(e.preventDefault(), handleCreate())}> + + +
+ + {#if filtered.length === 0} +

+ {searchQuery ? 'Keine Quizze gefunden.' : 'Noch keine Quizze. Leg eins oben an.'} +

+ {:else} + + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/quiz/PlayView.svelte b/apps/mana/apps/web/src/lib/modules/quiz/PlayView.svelte new file mode 100644 index 000000000..2bee8d075 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/PlayView.svelte @@ -0,0 +1,460 @@ + + + +
+
+ + {#if quiz} + {quiz.title} + {/if} + {#if !finished && total > 0} + {currentIndex + 1} / {total} + {/if} +
+ + {#if !quiz} +

Quiz nicht gefunden.

+ {:else if total === 0} +

Dieses Quiz hat noch keine Fragen.

+ {:else if finished} +
+
+ {scorePct}% + {correctCount} von {total} richtig +
+
    + {#each questions as q, i (q.id)} + {@const ans = answers[i]} +
  1. +
    + {#if ans?.correct}{:else}{/if} + {q.questionText} +
    + {#if q.type === 'text'} +

    + Deine Antwort: {ans?.textAnswer || '—'} +

    + {#if !ans?.correct} +

    Richtig: {q.options[0]?.text}

    + {/if} + {:else} +

    + Richtig: + + {q.options + .filter((o) => o.isCorrect) + .map((o) => o.text) + .join(', ')} + +

    + {/if} +
  2. + {/each} +
+
+ + +
+
+ {:else if current} +
+

{current.questionText}

+ + {#if current.type === 'text'} + + {#if revealed} + + {/if} + {:else} +
    + {#each current.options as opt (opt.id)} + {@const isSelected = selectedIds.includes(opt.id)} + {@const isCorrect = opt.isCorrect} +
  • + +
  • + {/each} +
+ {/if} + + {#if revealed && current.explanation} +

{current.explanation}

+ {/if} + +
+ {#if !revealed} + + {:else} + + {/if} +
+
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/quiz/collections.ts b/apps/mana/apps/web/src/lib/modules/quiz/collections.ts new file mode 100644 index 000000000..8152d7375 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/collections.ts @@ -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('quizzes'); +export const quizQuestionTable = db.table('quizQuestions'); +export const quizAttemptTable = db.table('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[], +}; diff --git a/apps/mana/apps/web/src/lib/modules/quiz/index.ts b/apps/mana/apps/web/src/lib/modules/quiz/index.ts new file mode 100644 index 000000000..c39098517 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/quiz/module.config.ts b/apps/mana/apps/web/src/lib/modules/quiz/module.config.ts new file mode 100644 index 000000000..51f9fd6a2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/module.config.ts @@ -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' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/quiz/queries.ts b/apps/mana/apps/web/src/lib/modules/quiz/queries.ts new file mode 100644 index 000000000..bc102334b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/queries.ts @@ -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('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('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('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('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 }; +} diff --git a/apps/mana/apps/web/src/lib/modules/quiz/stores/attempts.svelte.ts b/apps/mana/apps/web/src/lib/modules/quiz/stores/attempts.svelte.ts new file mode 100644 index 000000000..71574ff9c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/stores/attempts.svelte.ts @@ -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 { + 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() }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts b/apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts new file mode 100644 index 000000000..fc4aad42a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts @@ -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 { + 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 + > + ) { + const diff: Partial = { ...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 + > + ) { + const diff: Partial = { ...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() }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/quiz/types.ts b/apps/mana/apps/web/src/lib/modules/quiz/types.ts new file mode 100644 index 000000000..a58acb0a0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/types.ts @@ -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 = { + single: 'Single Choice', + multi: 'Multiple Choice', + truefalse: 'Wahr / Falsch', + text: 'Texteingabe', +}; diff --git a/apps/mana/apps/web/src/routes/(app)/quiz/+page.svelte b/apps/mana/apps/web/src/routes/(app)/quiz/+page.svelte new file mode 100644 index 000000000..7c4709e80 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/quiz/+page.svelte @@ -0,0 +1,9 @@ + + + + Quiz - Mana + + + {}} goBack={() => history.back()} params={{}} /> diff --git a/apps/mana/apps/web/src/routes/(app)/quiz/[id]/edit/+page.svelte b/apps/mana/apps/web/src/routes/(app)/quiz/[id]/edit/+page.svelte new file mode 100644 index 000000000..e3295a672 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/quiz/[id]/edit/+page.svelte @@ -0,0 +1,14 @@ + + + + Quiz bearbeiten - Mana + + +{#key quizId} + +{/key} diff --git a/apps/mana/apps/web/src/routes/(app)/quiz/[id]/play/+page.svelte b/apps/mana/apps/web/src/routes/(app)/quiz/[id]/play/+page.svelte new file mode 100644 index 000000000..b38d2fd59 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/quiz/[id]/play/+page.svelte @@ -0,0 +1,14 @@ + + + + Quiz spielen - Mana + + +{#key quizId} + +{/key} diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 1559a71df..5fe16677a 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -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. + `` + ), picture: svgToDataUrl(pictureSvg), quotes: svgToDataUrl(quotesSvg), wisekeep: svgToDataUrl(wisekeepSvg), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index e831e58b8..6e005c463 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -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',