Aller au contenu

MCQ Revision Module — Technical Specifications

Version française
Back to index


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;

Next steps