Skip to content

Module Révision QCM — Spécifications techniques

English version
Retour au sommaire


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;

Étapes suivantes