Module Révision QCM — Spécifications techniques
Sources :
docs/SPEC_TECHNIQUE_MODULE_REVISION_QCM.md,src/lib/quizUtils.ts,src/app/api/quiz/
Architecture du module
Routes frontend
src/app/[language]/revision/
├── page.tsx ← Liste des catégories disponibles
└── [category]/
└── page.tsx ← Session QCM pour une catégorie
URLs générées :
/pul/revision → Liste des catégories Pular
/pul/revision/famille → QCM catégorie "Famille"
/wol/revision/les-animaux → QCM catégorie "Les animaux"
Le slug de catégorie est généré par slugifyTheme() depuis le champ theme de la table cards.
Routes API
| Endpoint | Méthode | Description |
|---|---|---|
POST /api/quiz/start |
POST | Crée une session (ligne en BDD) |
POST /api/quiz/complete |
POST | Enregistre le score final |
GET /api/admin/quiz/stats |
GET | Statistiques quiz (admin) |
Fichiers clés
src/lib/quizUtils.ts ← Logique QCM (fonctions pures)
src/data/quiz-distractors.ts ← Distracteurs statiques par thème
src/types/index.ts ← Types TypeScript (QuizItem, QuizQuestion, QuizSession)
Logique métier (src/lib/quizUtils.ts)
Nombre de questions : getQuestionCount()
export function getQuestionCount(wordCount: number): number {
if (wordCount <= 10) return 15;
if (wordCount <= 19) return 18;
return 20;
}
| Nb mots dans la catégorie | Questions par session |
|---|---|
| ≤ 10 | 15 |
| 11 à 19 | 18 |
| ≥ 20 | 20 |
Le nombre de questions peut dépasser le nombre de mots uniques dans la catégorie (cycling).
Génération du quiz : generateQuizSession()
export function generateQuizSession(
categoryItems: QuizItem[],
questionCount: number
): QuizSession {
// Prérequis : minimum 4 items pour avoir 1 bonne réponse + 3 mauvaises
if (categoryItems.length < 4) {
throw new Error("Pas assez d'items pour générer un quiz (minimum 4)");
}
// 1. Construire le pool de distracteurs statiques (exclure les traductions déjà présentes)
const allCategoryTranslations = new Set(categoryItems.map(qi => qi.item.translation));
const distractorItems = buildDistractorItems(theme, allCategoryTranslations);
// 2. Shuffler les items de la catégorie (Fisher-Yates)
const shuffledCategory = fisherYatesShuffle(categoryItems);
// 3. Générer chaque question
for (let i = 0; i < questionCount; i++) {
// Cycling : si questionCount > categoryItems.length, on repart du début
const correctItem = shuffledCategory[i % shuffledCategory.length];
// Pool élargi : items catégorie + distracteurs statiques, shufflé
const wrongPool = fisherYatesShuffle([
...categoryItems.filter(item => item.item.translation !== correctTranslation),
...distractorItems.filter(item => item.item.translation !== correctTranslation),
]);
// Prendre 3 mauvaises réponses sans doublon de traduction
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);
}
}
// Shuffler les 4 options et mémoriser l'index de la bonne réponse
const options = fisherYatesShuffle([correctItem, ...wrongAnswers]);
const correctIndex = options.findIndex(o => o.item.id === correctItem.item.id);
questions.push({ correctItem, options, correctIndex });
}
}
Propriétés clés de la génération
| Propriété | Valeur |
|---|---|
| 4 options par question | ✅ toujours 1 bonne + 3 mauvaises |
| Toutes les options du MÊME thème | ✅ (items catégorie + distracteurs du même thème) |
| Pas de doublon de traduction | ✅ usedTranslations Set |
| Ordre des options aléatoire | ✅ Fisher-Yates shuffle final |
| Ordre des questions aléatoire | ✅ Fisher-Yates shuffle de categoryItems |
| Rejouabilité | ✅ shuffle différent à chaque session |
Distracteurs statiques (src/data/quiz-distractors.ts)
Les distracteurs statiques enrichissent le pool de mauvaises réponses quand la catégorie contient peu de mots. Ils sont organisés par thème, identique aux noms de thèmes dans la table cards.
// 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", ...],
// ...
};
Recherche insensible à la casse :
const themeKey = Object.keys(QUIZ_DISTRACTORS).find(
(k) => k.toLowerCase() === theme.toLowerCase()
);
Filtrage des doublons :
Les distracteurs dont la traduction existe déjà dans la catégorie sont exclus du pool.
Types TypeScript
// 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; // La bonne réponse (item réel avec audio possible)
options: QuizItem[]; // 4 options shufflées (1 bonne + 3 mauvaises)
correctIndex: number; // Index de la bonne réponse dans options[]
}
interface QuizSession {
questions: QuizQuestion[];
currentIndex: number; // Index de la question courante
}
interface QuizCategory {
theme: string; // Nom lisible : "Famille"
slug: string; // Slug URL : "famille"
wordCount: number; // Nombre de mots dans cette catégorie
questionCount: number; // Nombre de questions (15/18/20)
}
Flux utilisateur complet
1. GET /{code}/revision
→ Server Component : récupère les thèmes uniques depuis la table cards
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) pour chaque thème
→ Affiche la liste des QuizCategory
2. Clic sur une catégorie → GET /{code}/revision/{slug}
→ themeFromSlug(slug, categories) pour retrouver le thème original
→ Récupère tous les items de la catégorie :
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 → obtenir sessionId
→ generateQuizSession(categoryItems, questionCount)
3. Pour chaque question :
→ Affiche original_text (langue africaine)
→ Lecture audio automatique si audio_url disponible
→ Utilisateur clique sur une des 4 options
→ Feedback immédiat : vert (correct) / rouge (incorrect + affichage bonne réponse)
→ Question suivante après 1 seconde
4. Après la dernière question :
→ POST /api/quiz/complete (sessionId, score)
→ getScoreMention(correct, total) pour le message motivant
→ Affiche score + mention + options "Refaire" / "Autre catégorie"
Mentions selon le score
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: "Très bien !", message: "..." };
if (percent >= 50) return { emoji: "👍", title: "Bien !", message: "..." };
return { emoji: "💪", title: "Courage !", message: "..." };
}
| Score | Mention | Emoji |
|---|---|---|
| ≥ 90% | Excellent ! | 🏆 |
| ≥ 70% | Très bien ! | ⭐ |
| ≥ 50% | Bien ! | 👍 |
| < 50% | Courage ! | 💪 |
Génération des slugs
export function slugifyTheme(theme: string): string {
return theme
.toLowerCase()
.normalize("NFD") // Décomposer les accents
.replace(/[̀-ͯ]/g, "") // Supprimer les diacritiques
.replace(/[^a-z0-9\s-]/g, "") // Garder letters, chiffres, espaces, tirets
.replace(/\s+/g, "-") // Espaces → tirets
.replace(/-+/g, "-") // Tirets multiples → un seul
.trim();
}
Exemples :
| Thème | Slug |
|---|---|
| "Famille" | famille |
| "Les animaux" | les-animaux |
| "Nourriture & boissons" | nourriture-boissons |
| "À la maison" | a-la-maison |
Les slugs sont utilisés dans les URLs et lors du précachage offline (CACHE_PAGES précache /{code}/revision/{slug} pour chaque thème).
Précachage offline du module quiz
Lors du téléchargement offline, le module offline-download.ts précache toutes les pages de révision :
// Générer les slugs des thèmes word
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}`),
// + toutes les pages cartes mots/phrases
];
Le quiz est utilisable entièrement offline — les items sont dans IndexedDB, les pages en 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, -- slug URL du thème
category_name VARCHAR(255), -- nom lisible
total_questions INTEGER NOT NULL, -- 15, 18 ou 20
score INTEGER, -- null si session non terminée (abandonnée)
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ, -- null si session abandonnée
created_at TIMESTAMPTZ DEFAULT NOW()
);
Sessions abandonnées : completed_at IS NULL — non comptabilisées dans les statistiques admin.
API POST /api/quiz/start
{
"languageId": "uuid",
"categorySlug": "famille",
"categoryName": "Famille",
"totalQuestions": 15
}
Retourne : { "sessionId": "uuid" }
API POST /api/quiz/complete
{
"sessionId": "uuid",
"score": 12
}
Met à jour score et completed_at = NOW().
Requêtes SQL utiles
Catégories disponibles pour une langue
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 item représentatif par carte
WHERE c.language_id = $1
AND c.type = 'word'
AND c.theme IS NOT NULL
GROUP BY c.theme
ORDER BY c.theme;
Items d'une catégorie (pour générer le 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;
Statistiques quiz par langue
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;