MCQ Revision Module — Technical Specifications
Sources:
docs/SPEC_TECHNIQUE_MODULE_REVISION_QCM.md,src/lib/quizUtils.ts,src/app/api/quiz/
Module architecture
Frontend routes
src/app/[language]/revision/
├── page.tsx ← List of available categories
└── [category]/
└── page.tsx ← MCQ session for a category
Generated URLs:
/pul/revision → Pular category list
/pul/revision/famille → MCQ for the "Family" category
/wol/revision/les-animaux → MCQ for the "Animals" category
The category slug is generated by slugifyTheme() from the theme field in the cards table.
API routes
| Endpoint | Method | Description |
|---|---|---|
POST /api/quiz/start |
POST | Creates a session (DB row) |
POST /api/quiz/complete |
POST | Records the final score |
GET /api/admin/quiz/stats |
GET | Quiz statistics (admin) |
Key files
src/lib/quizUtils.ts ← MCQ logic (pure functions)
src/data/quiz-distractors.ts ← Static distractors by theme
src/types/index.ts ← TypeScript types (QuizItem, QuizQuestion, QuizSession)
Business logic (src/lib/quizUtils.ts)
Question count: getQuestionCount()
export function getQuestionCount(wordCount: number): number {
if (wordCount <= 10) return 15;
if (wordCount <= 19) return 18;
return 20;
}
| Number of words in the category | Questions per session |
|---|---|
| ≤ 10 | 15 |
| 11 to 19 | 18 |
| ≥ 20 | 20 |
The number of questions can exceed the number of unique words in the category (cycling).
Quiz generation: generateQuizSession()
export function generateQuizSession(
categoryItems: QuizItem[],
questionCount: number
): QuizSession {
// Prerequisite: minimum 4 items to have 1 correct answer + 3 wrong ones
if (categoryItems.length < 4) {
throw new Error("Not enough items to generate a quiz (minimum 4)");
}
// 1. Build the static distractor pool (exclude already-present translations)
const allCategoryTranslations = new Set(categoryItems.map(qi => qi.item.translation));
const distractorItems = buildDistractorItems(theme, allCategoryTranslations);
// 2. Shuffle category items (Fisher-Yates)
const shuffledCategory = fisherYatesShuffle(categoryItems);
// 3. Generate each question
for (let i = 0; i < questionCount; i++) {
// Cycling: if questionCount > categoryItems.length, start over from the beginning
const correctItem = shuffledCategory[i % shuffledCategory.length];
// Expanded pool: category items + static distractors, shuffled
const wrongPool = fisherYatesShuffle([
...categoryItems.filter(item => item.item.translation !== correctTranslation),
...distractorItems.filter(item => item.item.translation !== correctTranslation),
]);
// Pick 3 wrong answers without translation duplicates
const wrongAnswers: QuizItem[] = [];
const usedTranslations = new Set([correctTranslation]);
for (const item of wrongPool) {
if (wrongAnswers.length >= 3) break;
if (!usedTranslations.has(item.item.translation)) {
wrongAnswers.push(item);
usedTranslations.add(item.item.translation);
}
}
// Shuffle the 4 options and record the index of the correct answer
const options = fisherYatesShuffle([correctItem, ...wrongAnswers]);
const correctIndex = options.findIndex(o => o.item.id === correctItem.item.id);
questions.push({ correctItem, options, correctIndex });
}
}
Key properties of generation
| Property | Value |
|---|---|
| 4 options per question | ✅ always 1 correct + 3 wrong |
| All options from the SAME theme | ✅ (category items + same-theme distractors) |
| No translation duplicates | ✅ usedTranslations Set |
| Random option order | ✅ Fisher-Yates final shuffle |
| Random question order | ✅ Fisher-Yates shuffle of categoryItems |
| Replayability | ✅ different shuffle each session |
Static distractors (src/data/quiz-distractors.ts)
Static distractors enrich the wrong-answer pool when the category contains few words. They are organized by theme, matching the theme names in the cards table.
// src/data/quiz-distractors.ts
export const QUIZ_DISTRACTORS: Record<string, string[]> = {
"Famille": ["Cousin", "Neveu", "Belle-sœur", "Beau-frère", "Beau-père", ...],
"Animaux": ["Rhinocéros", "Éléphant", "Crocodile", "Autruche", ...],
// ...
};
Case-insensitive lookup:
const themeKey = Object.keys(QUIZ_DISTRACTORS).find(
(k) => k.toLowerCase() === theme.toLowerCase()
);
Duplicate filtering:
Distractors whose translation already exists in the category are excluded from the pool.
TypeScript types
// src/types/index.ts
interface QuizItem {
item: {
id: string;
card_id: string;
position: number;
original_text: string;
translation: string;
audio_url?: string;
created_at: string;
};
theme: string;
cardId: string;
}
interface QuizQuestion {
correctItem: QuizItem; // The correct answer (real item with possible audio)
options: QuizItem[]; // 4 shuffled options (1 correct + 3 wrong)
correctIndex: number; // Index of the correct answer in options[]
}
interface QuizSession {
questions: QuizQuestion[];
currentIndex: number; // Index of the current question
}
interface QuizCategory {
theme: string; // Human-readable name: "Family"
slug: string; // URL slug: "family"
wordCount: number; // Number of words in this category
questionCount: number; // Number of questions (15/18/20)
}
Complete user flow
1. GET /{code}/revision
→ Server Component: retrieves unique themes from the cards table
SELECT DISTINCT theme, COUNT(*) FROM cards
WHERE language_id = $1 AND type = 'word' AND theme IS NOT NULL
GROUP BY theme ORDER BY theme
→ getQuestionCount(wordCount) for each theme
→ Displays the QuizCategory list
2. Click on a category → GET /{code}/revision/{slug}
→ themeFromSlug(slug, categories) to recover the original theme
→ Retrieves all items in the category:
SELECT i.*, c.theme, c.id as card_id FROM items i
JOIN cards c ON i.card_id = c.id
WHERE c.language_id = $1 AND c.theme = $2 AND c.type = 'word'
→ POST /api/quiz/start → get sessionId
→ generateQuizSession(categoryItems, questionCount)
3. For each question:
→ Display original_text (African language)
→ Automatic audio playback if audio_url is available
→ User clicks on one of the 4 options
→ Immediate feedback: green (correct) / red (incorrect + display correct answer)
→ Next question after 1 second
4. After the last question:
→ POST /api/quiz/complete (sessionId, score)
→ getScoreMention(correct, total) for the motivational message
→ Display score + rating + options "Retry" / "Other category"
Score ratings
export function getScoreMention(correct: number, total: number) {
const percent = (correct / total) * 100;
if (percent >= 90) return { emoji: "🏆", title: "Excellent!", message: "..." };
if (percent >= 70) return { emoji: "⭐", title: "Very good!", message: "..." };
if (percent >= 50) return { emoji: "👍", title: "Good!", message: "..." };
return { emoji: "💪", title: "Keep going!", message: "..." };
}
| Score | Rating | Emoji |
|---|---|---|
| ≥ 90% | Excellent! | 🏆 |
| ≥ 70% | Very good! | ⭐ |
| ≥ 50% | Good! | 👍 |
| < 50% | Keep going! | 💪 |
Slug generation
export function slugifyTheme(theme: string): string {
return theme
.toLowerCase()
.normalize("NFD") // Decompose accents
.replace(/[̀-ͯ]/g, "") // Remove diacritics
.replace(/[^a-z0-9\s-]/g, "") // Keep letters, digits, spaces, hyphens
.replace(/\s+/g, "-") // Spaces → hyphens
.replace(/-+/g, "-") // Multiple hyphens → single hyphen
.trim();
}
Examples:
| Theme | Slug |
|---|---|
| "Famille" | famille |
| "Les animaux" | les-animaux |
| "Nourriture & boissons" | nourriture-boissons |
| "À la maison" | a-la-maison |
Slugs are used in URLs and during offline pre-caching (CACHE_PAGES pre-caches /{code}/revision/{slug} for each theme).
Offline pre-caching of the quiz module
During offline download, the offline-download.ts module pre-caches all revision pages:
// Generate word theme slugs
const revisionSlugs = [
...new Set(
cards
.filter((c: Card) => c.type === 'word' && c.theme)
.map((c: Card) => slugifyTheme(c.theme!))
),
];
const urlsToCache = [
`/${language.code}/revision`,
...(revisionSlugs as string[]).map((slug) => `/${language.code}/revision/${slug}`),
// + all word/phrase card pages
];
The quiz is usable entirely offline — items are in IndexedDB, pages are in cache.
Table quiz_sessions
CREATE TABLE quiz_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
language_id UUID REFERENCES languages(id) ON DELETE CASCADE,
category_slug VARCHAR(255) NOT NULL, -- URL slug of the theme
category_name VARCHAR(255), -- human-readable name
total_questions INTEGER NOT NULL, -- 15, 18, or 20
score INTEGER, -- null if session not completed (abandoned)
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ, -- null if session abandoned
created_at TIMESTAMPTZ DEFAULT NOW()
);
Abandoned sessions: completed_at IS NULL — not counted in admin statistics.
API POST /api/quiz/start
{
"languageId": "uuid",
"categorySlug": "famille",
"categoryName": "Family",
"totalQuestions": 15
}
Returns: { "sessionId": "uuid" }
API POST /api/quiz/complete
{
"sessionId": "uuid",
"score": 12
}
Updates score and completed_at = NOW().
Useful SQL queries
Available categories for a language
SELECT
c.theme,
COUNT(DISTINCT c.id) AS card_count,
COUNT(i.id) AS word_count
FROM cards c
JOIN items i ON i.card_id = c.id AND i.position = 1 -- 1 representative item per card
WHERE c.language_id = $1
AND c.type = 'word'
AND c.theme IS NOT NULL
GROUP BY c.theme
ORDER BY c.theme;
Items in a category (to generate the quiz)
SELECT
i.id, i.card_id, i.position, i.original_text, i.translation, i.audio_url,
c.theme
FROM items i
JOIN cards c ON i.card_id = c.id
WHERE c.language_id = $1
AND c.theme = $2
AND c.type = 'word'
ORDER BY c.card_number, i.position;
Quiz statistics by language
SELECT
l.name AS language_name,
COUNT(*) AS total_sessions,
COUNT(CASE WHEN qs.completed_at IS NOT NULL THEN 1 END) AS completed,
COUNT(CASE WHEN qs.completed_at IS NULL THEN 1 END) AS abandoned,
ROUND(AVG(CASE WHEN qs.completed_at IS NOT NULL THEN qs.score END), 1) AS avg_score,
ROUND(AVG(CASE WHEN qs.completed_at IS NOT NULL
THEN qs.score::numeric / qs.total_questions * 100 END), 1) AS avg_pct
FROM quiz_sessions qs
JOIN languages l ON qs.language_id = l.id
WHERE ($1::date IS NULL OR qs.started_at::date >= $1)
AND ($2::date IS NULL OR qs.started_at::date <= $2)
GROUP BY l.id, l.name
ORDER BY total_sessions DESC;