---
id: "ET-EDU-005"
title: "Quiz and Evaluation Engine"
type: "Specification"
status: "Done"
rf_parent: "RF-EDU-004"
epic: "OQI-002"
version: "1.0"
created_date: "2025-12-05"
updated_date: "2026-01-04"
---

# ET-EDU-005: Motor de Evaluaciones y Quizzes

**Versión:** 1.0.0
**Fecha:** 2025-12-05
**Épica:** OQI-002 - Módulo Educativo
**Componente:** Backend/Frontend

---

## Descripción

Define el sistema completo de evaluaciones y quizzes del módulo educativo, incluyendo tipos de preguntas, algoritmo de scoring, validación de respuestas, gestión de intentos, timer, retroalimentación y estadísticas.

---

## Arquitectura

```
┌─────────────────────────────────────────────────────────────────┐
│                    Quiz Engine Architecture                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                  Frontend (React)                        │  │
│  │                                                          │  │
│  │  ┌─────────────┐  ┌──────────────┐  ┌──────────────┐   │  │
│  │  │ QuizPage    │  │ QuizQuestion │  │ QuizResults  │   │  │
│  │  │ - Timer     │  │ - Types      │  │ - Score      │   │  │
│  │  │ - Progress  │  │ - Validation │  │ - Review     │   │  │
│  │  └─────────────┘  └──────────────┘  └──────────────┘   │  │
│  └──────────────────────┬───────────────────────────────────┘  │
│                         │                                       │
│                         v                                       │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                Backend Quiz Service                      │  │
│  │                                                          │  │
│  │  ┌────────────────┐  ┌─────────────────┐               │  │
│  │  │ Quiz Manager   │  │ Scoring Engine  │               │  │
│  │  │ - Get quiz     │  │ - Calculate     │               │  │
│  │  │ - Start attempt│  │ - Validate      │               │  │
│  │  │ - Submit       │  │ - Grade         │               │  │
│  │  └────────────────┘  └─────────────────┘               │  │
│  │                                                          │  │
│  │  ┌────────────────┐  ┌─────────────────┐               │  │
│  │  │ Attempt Manager│  │ Analytics       │               │  │
│  │  │ - Track time   │  │ - Statistics    │               │  │
│  │  │ - Limit checks │  │ - Insights      │               │  │
│  │  └────────────────┘  └─────────────────┘               │  │
│  └──────────────────────┬───────────────────────────────────┘  │
│                         │                                       │
│                         v                                       │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    PostgreSQL                            │  │
│  │  - quizzes                                               │  │
│  │  - quiz_questions                                        │  │
│  │  - quiz_attempts                                         │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
```

---

## Especificación Detallada

### 1. TIPOS DE PREGUNTAS

#### 1.1 Multiple Choice (Opción Única)

```typescript
interface MultipleChoiceQuestion {
  id: string;
  question_type: 'multiple_choice';
  question_text: string;
  options: Array<{
    id: string;
    text: string;
    isCorrect: boolean; // Solo una puede ser true
  }>;
  points: number;
  explanation: string;
}

// Ejemplo
const multipleChoiceExample = {
  id: 'q1',
  question_type: 'multiple_choice',
  question_text: '¿Qué es un stop loss?',
  options: [
    {
      id: 'opt1',
      text: 'Una orden para comprar acciones',
      isCorrect: false
    },
    {
      id: 'opt2',
      text: 'Una orden para limitar pérdidas',
      isCorrect: true
    },
    {
      id: 'opt3',
      text: 'Un indicador técnico',
      isCorrect: false
    },
    {
      id: 'opt4',
      text: 'Un patrón de velas',
      isCorrect: false
    }
  ],
  points: 10,
  explanation: 'Un stop loss es una orden diseñada para limitar las pérdidas de un inversor en una posición.'
};
```

#### 1.2 True/False

```typescript
interface TrueFalseQuestion {
  id: string;
  question_type: 'true_false';
  question_text: string;
  options: [
    { id: 'true', text: 'Verdadero', isCorrect: boolean },
    { id: 'false', text: 'Falso', isCorrect: boolean }
  ];
  points: number;
  explanation: string;
}

// Ejemplo
const trueFalseExample = {
  id: 'q2',
  question_type: 'true_false',
  question_text: 'El RSI es un indicador de momento que oscila entre 0 y 100.',
  options: [
    { id: 'true', text: 'Verdadero', isCorrect: true },
    { id: 'false', text: 'Falso', isCorrect: false }
  ],
  points: 5,
  explanation: 'El RSI (Relative Strength Index) efectivamente oscila entre 0 y 100.'
};
```

#### 1.3 Multiple Select (Selección Múltiple)

```typescript
interface MultipleSelectQuestion {
  id: string;
  question_type: 'multiple_select';
  question_text: string;
  options: Array<{
    id: string;
    text: string;
    isCorrect: boolean; // Múltiples pueden ser true
  }>;
  points: number;
  explanation: string;
  partial_credit: boolean; // Dar puntos parciales si algunas respuestas son correctas
}

// Ejemplo
const multipleSelectExample = {
  id: 'q3',
  question_type: 'multiple_select',
  question_text: '¿Cuáles de los siguientes son indicadores de tendencia? (Selecciona todas las correctas)',
  options: [
    { id: 'opt1', text: 'Media móvil', isCorrect: true },
    { id: 'opt2', text: 'MACD', isCorrect: true },
    { id: 'opt3', text: 'RSI', isCorrect: false },
    { id: 'opt4', text: 'Bandas de Bollinger', isCorrect: true },
    { id: 'opt5', text: 'Stochastic', isCorrect: false }
  ],
  points: 15,
  partial_credit: true,
  explanation: 'Los indicadores de tendencia incluyen medias móviles, MACD y Bandas de Bollinger. RSI y Stochastic son indicadores de momentum.'
};
```

#### 1.4 Fill in the Blank

```typescript
interface FillBlankQuestion {
  id: string;
  question_type: 'fill_blank';
  question_text: string; // Usar {{blank}} para indicar dónde va la respuesta
  correct_answer: string | string[]; // Múltiples respuestas aceptadas
  case_sensitive: boolean;
  exact_match: boolean; // Si es false, acepta respuestas similares
  points: number;
  explanation: string;
}

// Ejemplo
const fillBlankExample = {
  id: 'q4',
  question_type: 'fill_blank',
  question_text: 'El patrón de velas {{blank}} indica una posible reversión alcista.',
  correct_answer: ['martillo', 'hammer', 'martillo invertido'],
  case_sensitive: false,
  exact_match: false,
  points: 10,
  explanation: 'Un patrón de martillo indica una posible reversión alcista cuando aparece en una tendencia bajista.'
};
```

#### 1.5 Code Challenge

```typescript
interface CodeChallengeQuestion {
  id: string;
  question_type: 'code_challenge';
  question_text: string;
  language: 'python' | 'javascript' | 'pine-script';
  starter_code: string;
  test_cases: Array<{
    input: any;
    expected_output: any;
  }>;
  points: number;
  explanation: string;
}

// Ejemplo
const codeChallengeExample = {
  id: 'q5',
  question_type: 'code_challenge',
  question_text: 'Implementa una función que calcule el RSI de 14 períodos.',
  language: 'python',
  starter_code: `def calculate_rsi(prices, period=14):
    # Tu código aquí
    pass`,
  test_cases: [
    {
      input: { prices: [44, 45, 46, 47, 46, 45], period: 14 },
      expected_output: 50.0
    }
  ],
  points: 25,
  explanation: 'El RSI se calcula comparando ganancias y pérdidas promedio durante un período.'
};
```

---

### 2. SCORING ENGINE

#### Algoritmo de Puntuación

```typescript
// services/quiz/scoring.service.ts

export class QuizScoringService {
  /**
   * Calcula la puntuación de un intento de quiz
   */
  calculateScore(
    questions: QuizQuestion[],
    userAnswers: UserAnswer[]
  ): ScoreResult {
    let totalPoints = 0;
    let maxPoints = 0;
    const results: QuestionResult[] = [];

    for (const question of questions) {
      const userAnswer = userAnswers.find(a => a.question_id === question.id);
      const questionResult = this.scoreQuestion(question, userAnswer);

      totalPoints += questionResult.points_earned;
      maxPoints += question.points;
      results.push(questionResult);
    }

    const percentage = (totalPoints / maxPoints) * 100;

    return {
      total_points: totalPoints,
      max_points: maxPoints,
      percentage: percentage,
      results: results
    };
  }

  /**
   * Puntúa una pregunta individual
   */
  private scoreQuestion(
    question: QuizQuestion,
    userAnswer?: UserAnswer
  ): QuestionResult {
    if (!userAnswer) {
      return {
        question_id: question.id,
        is_correct: false,
        points_earned: 0,
        max_points: question.points,
        feedback: 'No se proporcionó respuesta'
      };
    }

    switch (question.question_type) {
      case 'multiple_choice':
      case 'true_false':
        return this.scoreMultipleChoice(question, userAnswer);

      case 'multiple_select':
        return this.scoreMultipleSelect(question, userAnswer);

      case 'fill_blank':
        return this.scoreFillBlank(question, userAnswer);

      case 'code_challenge':
        return this.scoreCodeChallenge(question, userAnswer);

      default:
        throw new Error(`Unsupported question type: ${question.question_type}`);
    }
  }

  /**
   * Puntúa Multiple Choice / True False
   */
  private scoreMultipleChoice(
    question: MultipleChoiceQuestion,
    userAnswer: UserAnswer
  ): QuestionResult {
    const correctOption = question.options.find(opt => opt.isCorrect);
    const isCorrect = userAnswer.answer === correctOption?.id;

    return {
      question_id: question.id,
      is_correct: isCorrect,
      points_earned: isCorrect ? question.points : 0,
      max_points: question.points,
      user_answer: userAnswer.answer,
      correct_answer: correctOption?.id,
      feedback: isCorrect ? '¡Correcto!' : question.explanation
    };
  }

  /**
   * Puntúa Multiple Select
   */
  private scoreMultipleSelect(
    question: MultipleSelectQuestion,
    userAnswer: UserAnswer
  ): QuestionResult {
    const correctOptions = question.options
      .filter(opt => opt.isCorrect)
      .map(opt => opt.id);

    const userAnswerArray = Array.isArray(userAnswer.answer)
      ? userAnswer.answer
      : [userAnswer.answer];

    // Todas correctas
    const allCorrect = correctOptions.every(opt =>
      userAnswerArray.includes(opt)
    ) && userAnswerArray.every(ans =>
      correctOptions.includes(ans)
    );

    if (allCorrect) {
      return {
        question_id: question.id,
        is_correct: true,
        points_earned: question.points,
        max_points: question.points,
        user_answer: userAnswerArray,
        correct_answer: correctOptions,
        feedback: '¡Correcto!'
      };
    }

    // Crédito parcial
    if (question.partial_credit) {
      const correctSelected = userAnswerArray.filter(ans =>
        correctOptions.includes(ans)
      ).length;

      const incorrectSelected = userAnswerArray.filter(ans =>
        !correctOptions.includes(ans)
      ).length;

      const missedCorrect = correctOptions.filter(opt =>
        !userAnswerArray.includes(opt)
      ).length;

      // Fórmula: (correctas - incorrectas) / total correctas
      const score = Math.max(
        0,
        (correctSelected - incorrectSelected) / correctOptions.length
      );

      const pointsEarned = Math.floor(question.points * score);

      return {
        question_id: question.id,
        is_correct: false,
        points_earned: pointsEarned,
        max_points: question.points,
        user_answer: userAnswerArray,
        correct_answer: correctOptions,
        feedback: `Crédito parcial: ${pointsEarned}/${question.points} puntos. ${question.explanation}`
      };
    }

    // Sin crédito parcial
    return {
      question_id: question.id,
      is_correct: false,
      points_earned: 0,
      max_points: question.points,
      user_answer: userAnswerArray,
      correct_answer: correctOptions,
      feedback: question.explanation
    };
  }

  /**
   * Puntúa Fill in the Blank
   */
  private scoreFillBlank(
    question: FillBlankQuestion,
    userAnswer: UserAnswer
  ): QuestionResult {
    const correctAnswers = Array.isArray(question.correct_answer)
      ? question.correct_answer
      : [question.correct_answer];

    let isCorrect = false;
    let userAnswerStr = String(userAnswer.answer);

    if (!question.case_sensitive) {
      userAnswerStr = userAnswerStr.toLowerCase();
    }

    for (const correctAnswer of correctAnswers) {
      let correctStr = question.case_sensitive
        ? correctAnswer
        : correctAnswer.toLowerCase();

      if (question.exact_match) {
        if (userAnswerStr.trim() === correctStr.trim()) {
          isCorrect = true;
          break;
        }
      } else {
        // Fuzzy match (permite pequeñas diferencias)
        const similarity = this.calculateSimilarity(
          userAnswerStr.trim(),
          correctStr.trim()
        );
        if (similarity > 0.85) {
          isCorrect = true;
          break;
        }
      }
    }

    return {
      question_id: question.id,
      is_correct: isCorrect,
      points_earned: isCorrect ? question.points : 0,
      max_points: question.points,
      user_answer: userAnswer.answer,
      correct_answer: correctAnswers[0],
      feedback: isCorrect ? '¡Correcto!' : question.explanation
    };
  }

  /**
   * Puntúa Code Challenge
   */
  private async scoreCodeChallenge(
    question: CodeChallengeQuestion,
    userAnswer: UserAnswer
  ): Promise<QuestionResult> {
    const userCode = String(userAnswer.answer);
    let passedTests = 0;
    const testResults = [];

    for (const testCase of question.test_cases) {
      const result = await this.executeCode(
        userCode,
        testCase.input,
        question.language
      );

      const passed = this.compareOutputs(result, testCase.expected_output);

      testResults.push({
        input: testCase.input,
        expected: testCase.expected_output,
        actual: result,
        passed: passed
      });

      if (passed) passedTests++;
    }

    const allPassed = passedTests === question.test_cases.length;
    const partialPoints = Math.floor(
      (passedTests / question.test_cases.length) * question.points
    );

    return {
      question_id: question.id,
      is_correct: allPassed,
      points_earned: allPassed ? question.points : partialPoints,
      max_points: question.points,
      user_answer: userCode,
      test_results: testResults,
      feedback: allPassed
        ? '¡Todos los tests pasaron!'
        : `${passedTests}/${question.test_cases.length} tests pasaron. ${question.explanation}`
    };
  }

  /**
   * Calcula similitud entre dos strings (Levenshtein distance)
   */
  private calculateSimilarity(str1: string, str2: string): number {
    const maxLength = Math.max(str1.length, str2.length);
    if (maxLength === 0) return 1.0;

    const distance = this.levenshteinDistance(str1, str2);
    return 1 - distance / maxLength;
  }

  private levenshteinDistance(str1: string, str2: string): number {
    const matrix: number[][] = [];

    for (let i = 0; i <= str2.length; i++) {
      matrix[i] = [i];
    }

    for (let j = 0; j <= str1.length; j++) {
      matrix[0][j] = j;
    }

    for (let i = 1; i <= str2.length; i++) {
      for (let j = 1; j <= str1.length; j++) {
        if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
          matrix[i][j] = matrix[i - 1][j - 1];
        } else {
          matrix[i][j] = Math.min(
            matrix[i - 1][j - 1] + 1,
            matrix[i][j - 1] + 1,
            matrix[i - 1][j] + 1
          );
        }
      }
    }

    return matrix[str2.length][str1.length];
  }

  /**
   * Ejecuta código del usuario (sandboxed)
   */
  private async executeCode(
    code: string,
    input: any,
    language: string
  ): Promise<any> {
    // TODO: Implementar ejecución segura usando Docker o serverless functions
    // Por ahora, placeholder
    return null;
  }

  private compareOutputs(actual: any, expected: any): boolean {
    return JSON.stringify(actual) === JSON.stringify(expected);
  }
}

// Types
interface QuizQuestion {
  id: string;
  question_type: string;
  points: number;
  [key: string]: any;
}

interface UserAnswer {
  question_id: string;
  answer: string | string[];
}

interface QuestionResult {
  question_id: string;
  is_correct: boolean;
  points_earned: number;
  max_points: number;
  user_answer?: any;
  correct_answer?: any;
  feedback: string;
  test_results?: any[];
}

interface ScoreResult {
  total_points: number;
  max_points: number;
  percentage: number;
  results: QuestionResult[];
}
```

---

### 3. ATTEMPT MANAGEMENT

#### Quiz Attempt Flow

```typescript
// services/quiz/attempt.service.ts

export class QuizAttemptService {
  /**
   * Inicia un nuevo intento de quiz
   */
  async startAttempt(
    userId: string,
    quizId: string,
    enrollmentId: string
  ): Promise<QuizAttempt> {
    // 1. Verificar que el usuario tiene acceso al quiz
    await this.verifyAccess(userId, quizId, enrollmentId);

    // 2. Obtener configuración del quiz
    const quiz = await this.getQuiz(quizId);

    // 3. Verificar límite de intentos
    if (quiz.max_attempts) {
      const previousAttempts = await this.getPreviousAttempts(userId, quizId);
      if (previousAttempts.length >= quiz.max_attempts) {
        throw new Error('NO_ATTEMPTS_REMAINING');
      }
    }

    // 4. Crear nuevo intento
    const attempt = await db.quiz_attempts.create({
      user_id: userId,
      quiz_id: quizId,
      enrollment_id: enrollmentId,
      started_at: new Date(),
      is_completed: false
    });

    // 5. Obtener preguntas (shuffled si corresponde)
    const questions = await this.getQuizQuestions(quiz, attempt.id);

    return {
      id: attempt.id,
      quiz_id: quizId,
      started_at: attempt.started_at,
      time_limit_expires_at: quiz.time_limit_minutes
        ? new Date(Date.now() + quiz.time_limit_minutes * 60 * 1000)
        : null,
      questions: questions
    };
  }

  /**
   * Envía respuestas del quiz
   */
  async submitAttempt(
    attemptId: string,
    userId: string,
    answers: UserAnswer[]
  ): Promise<AttemptResult> {
    // 1. Verificar que el attempt existe y pertenece al usuario
    const attempt = await this.getAttempt(attemptId);
    if (attempt.user_id !== userId) {
      throw new Error('UNAUTHORIZED');
    }

    // 2. Verificar que no ha expirado el tiempo
    if (attempt.time_limit_expires_at) {
      if (new Date() > new Date(attempt.time_limit_expires_at)) {
        throw new Error('QUIZ_TIME_EXPIRED');
      }
    }

    // 3. Verificar que no se ha completado previamente
    if (attempt.is_completed) {
      throw new Error('ATTEMPT_ALREADY_COMPLETED');
    }

    // 4. Obtener preguntas del quiz
    const quiz = await this.getQuiz(attempt.quiz_id);
    const questions = await this.getQuestions(quiz.id);

    // 5. Calcular puntuación
    const scoringService = new QuizScoringService();
    const scoreResult = scoringService.calculateScore(questions, answers);

    // 6. Determinar si pasó
    const isPassed = scoreResult.percentage >= quiz.passing_score_percentage;

    // 7. Calcular XP ganado
    let xpEarned = 0;
    if (isPassed) {
      xpEarned = quiz.xp_reward;
      // Bonus por puntaje perfecto
      if (scoreResult.percentage === 100) {
        xpEarned += quiz.xp_perfect_score_bonus;
      }
    }

    // 8. Calcular tiempo tomado
    const timeTaken = Math.floor(
      (Date.now() - new Date(attempt.started_at).getTime()) / 1000
    );

    // 9. Actualizar attempt en la BD
    await db.quiz_attempts.update(attemptId, {
      is_completed: true,
      completed_at: new Date(),
      is_passed: isPassed,
      user_answers: answers,
      score_points: scoreResult.total_points,
      max_points: scoreResult.max_points,
      score_percentage: scoreResult.percentage,
      time_taken_seconds: timeTaken,
      xp_earned: xpEarned
    });

    // 10. Actualizar progreso del usuario si pasó
    if (isPassed) {
      await this.updateUserProgress(userId, quiz, xpEarned);
    }

    // 11. Verificar si ganó achievement
    const achievement = await this.checkAchievements(
      userId,
      quiz,
      scoreResult
    );

    return {
      id: attemptId,
      is_completed: true,
      is_passed: isPassed,
      score_points: scoreResult.total_points,
      max_points: scoreResult.max_points,
      score_percentage: scoreResult.percentage,
      time_taken_seconds: timeTaken,
      xp_earned: xpEarned,
      results: quiz.show_correct_answers ? scoreResult.results : undefined,
      achievement_earned: achievement
    };
  }

  /**
   * Obtiene el resultado de un intento completado
   */
  async getAttemptResult(
    attemptId: string,
    userId: string
  ): Promise<AttemptResult> {
    const attempt = await this.getAttempt(attemptId);

    // Verificar ownership
    if (attempt.user_id !== userId) {
      throw new Error('UNAUTHORIZED');
    }

    // Solo mostrar resultados de intentos completados
    if (!attempt.is_completed) {
      throw new Error('ATTEMPT_NOT_COMPLETED');
    }

    const quiz = await this.getQuiz(attempt.quiz_id);

    return {
      id: attempt.id,
      is_completed: attempt.is_completed,
      is_passed: attempt.is_passed,
      score_points: attempt.score_points,
      max_points: attempt.max_points,
      score_percentage: attempt.score_percentage,
      time_taken_seconds: attempt.time_taken_seconds,
      xp_earned: attempt.xp_earned,
      results: quiz.show_correct_answers
        ? this.parseResults(attempt.user_answers)
        : undefined
    };
  }

  private async getQuizQuestions(
    quiz: Quiz,
    attemptId: string
  ): Promise<QuizQuestion[]> {
    let questions = await db.quiz_questions.findMany({
      where: { quiz_id: quiz.id },
      orderBy: { display_order: 'asc' }
    });

    // Shuffle questions si corresponde
    if (quiz.shuffle_questions) {
      questions = this.shuffleArray(questions);
    }

    // Shuffle answers si corresponde
    if (quiz.shuffle_answers) {
      questions = questions.map(q => ({
        ...q,
        options: q.options ? this.shuffleArray(q.options) : undefined
      }));
    }

    // Remover respuestas correctas antes de enviar al frontend
    return questions.map(q => ({
      ...q,
      options: q.options?.map(opt => ({
        id: opt.id,
        text: opt.text
        // isCorrect removido
      })),
      correct_answer: undefined
    }));
  }

  private shuffleArray<T>(array: T[]): T[] {
    const shuffled = [...array];
    for (let i = shuffled.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }
    return shuffled;
  }

  private async checkAchievements(
    userId: string,
    quiz: Quiz,
    scoreResult: ScoreResult
  ): Promise<Achievement | null> {
    // Perfect score achievement
    if (scoreResult.percentage === 100) {
      const achievement = await db.user_achievements.create({
        user_id: userId,
        achievement_type: 'quiz_perfect_score',
        title: 'Puntaje Perfecto',
        description: `Obtuviste 100% en el quiz "${quiz.title}"`,
        badge_icon_url: '/badges/perfect-score.svg',
        quiz_id: quiz.id,
        xp_bonus: 50,
        earned_at: new Date()
      });

      return achievement;
    }

    return null;
  }
}
```

---

### 4. TIMER IMPLEMENTATION

#### Frontend Timer Component

```typescript
// components/quiz/QuizTimer.tsx
import React, { useState, useEffect } from 'react';

interface QuizTimerProps {
  expiresAt: Date | null;
  onExpire: () => void;
}

export const QuizTimer: React.FC<QuizTimerProps> = ({
  expiresAt,
  onExpire
}) => {
  const [timeRemaining, setTimeRemaining] = useState<number>(0);

  useEffect(() => {
    if (!expiresAt) return;

    const calculateTimeRemaining = () => {
      const now = Date.now();
      const expiry = new Date(expiresAt).getTime();
      const remaining = Math.max(0, Math.floor((expiry - now) / 1000));
      return remaining;
    };

    setTimeRemaining(calculateTimeRemaining());

    const interval = setInterval(() => {
      const remaining = calculateTimeRemaining();
      setTimeRemaining(remaining);

      if (remaining <= 0) {
        clearInterval(interval);
        onExpire();
      }
    }, 1000);

    return () => clearInterval(interval);
  }, [expiresAt, onExpire]);

  if (!expiresAt) return null;

  const minutes = Math.floor(timeRemaining / 60);
  const seconds = timeRemaining % 60;

  const isUrgent = timeRemaining < 300; // Últimos 5 minutos

  return (
    <div
      className={cn(
        'flex items-center gap-2 px-4 py-2 rounded-lg font-mono text-lg',
        isUrgent
          ? 'bg-red-100 text-red-700 animate-pulse'
          : 'bg-gray-100 text-gray-700'
      )}
    >
      <ClockIcon className="w-5 h-5" />
      <span>
        {String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
      </span>
    </div>
  );
};
```

---

### 5. ANALYTICS & STATISTICS

#### Quiz Statistics

```typescript
// services/quiz/analytics.service.ts

export class QuizAnalyticsService {
  /**
   * Obtiene estadísticas de un quiz
   */
  async getQuizStatistics(quizId: string): Promise<QuizStatistics> {
    const attempts = await db.quiz_attempts.findMany({
      where: { quiz_id: quizId, is_completed: true }
    });

    if (attempts.length === 0) {
      return {
        total_attempts: 0,
        average_score: 0,
        pass_rate: 0,
        average_time: 0
      };
    }

    const totalAttempts = attempts.length;
    const passedAttempts = attempts.filter(a => a.is_passed).length;
    const totalScore = attempts.reduce((sum, a) => sum + a.score_percentage, 0);
    const totalTime = attempts.reduce((sum, a) => sum + (a.time_taken_seconds || 0), 0);

    return {
      total_attempts: totalAttempts,
      unique_users: new Set(attempts.map(a => a.user_id)).size,
      average_score: totalScore / totalAttempts,
      pass_rate: (passedAttempts / totalAttempts) * 100,
      average_time: totalTime / totalAttempts,
      score_distribution: this.calculateScoreDistribution(attempts),
      question_difficulty: await this.calculateQuestionDifficulty(quizId)
    };
  }

  /**
   * Calcula distribución de puntajes
   */
  private calculateScoreDistribution(attempts: QuizAttempt[]): ScoreDistribution {
    const ranges = {
      '0-25': 0,
      '26-50': 0,
      '51-75': 0,
      '76-90': 0,
      '91-100': 0
    };

    attempts.forEach(attempt => {
      const score = attempt.score_percentage;
      if (score <= 25) ranges['0-25']++;
      else if (score <= 50) ranges['26-50']++;
      else if (score <= 75) ranges['51-75']++;
      else if (score <= 90) ranges['76-90']++;
      else ranges['91-100']++;
    });

    return ranges;
  }

  /**
   * Calcula dificultad de cada pregunta
   */
  private async calculateQuestionDifficulty(
    quizId: string
  ): Promise<QuestionDifficulty[]> {
    const questions = await db.quiz_questions.findMany({
      where: { quiz_id: quizId }
    });

    const difficulties = [];

    for (const question of questions) {
      const attempts = await db.quiz_attempts.findMany({
        where: {
          quiz_id: quizId,
          is_completed: true
        }
      });

      let correctCount = 0;
      let totalCount = 0;

      attempts.forEach(attempt => {
        const answer = attempt.user_answers?.find(
          (a: any) => a.question_id === question.id
        );
        if (answer) {
          totalCount++;
          // Check if answer is correct (simplificado)
          if (answer.is_correct) correctCount++;
        }
      });

      const successRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0;

      difficulties.push({
        question_id: question.id,
        question_text: question.question_text,
        success_rate: successRate,
        total_attempts: totalCount,
        difficulty_label:
          successRate > 75 ? 'Fácil' :
          successRate > 50 ? 'Medio' :
          successRate > 25 ? 'Difícil' : 'Muy Difícil'
      });
    }

    return difficulties;
  }
}

interface QuizStatistics {
  total_attempts: number;
  unique_users: number;
  average_score: number;
  pass_rate: number;
  average_time: number;
  score_distribution: ScoreDistribution;
  question_difficulty: QuestionDifficulty[];
}

interface ScoreDistribution {
  '0-25': number;
  '26-50': number;
  '51-75': number;
  '76-90': number;
  '91-100': number;
}

interface QuestionDifficulty {
  question_id: string;
  question_text: string;
  success_rate: number;
  total_attempts: number;
  difficulty_label: string;
}
```

---

## Interfaces/Tipos

Ver sección anterior (incluidas en código)

---

## Configuración

```bash
# Quiz Settings
QUIZ_DEFAULT_TIME_LIMIT=30
QUIZ_MAX_ATTEMPTS_DEFAULT=3
QUIZ_PASSING_SCORE_DEFAULT=70
QUIZ_AUTO_SUBMIT_ON_EXPIRE=true

# Code Execution (para code challenges)
CODE_EXECUTION_TIMEOUT=5000
CODE_EXECUTION_MEMORY_LIMIT=256
CODE_SANDBOX_DOCKER_IMAGE=python:3.11-slim
```

---

## Dependencias

```json
{
  "dependencies": {
    "string-similarity": "^4.0.4"
  }
}
```

---

## Consideraciones de Seguridad

1. **Nunca enviar respuestas correctas al frontend antes de submit**
2. **Validar todas las respuestas en el backend**
3. **Verificar ownership de attempts**
4. **Prevenir cheating con timer**
5. **Sandboxing para code challenges**
6. **Rate limiting en endpoints de quiz**

---

## Testing

```typescript
describe('QuizScoringService', () => {
  it('should score multiple choice correctly', () => {
    // Test implementation
  });

  it('should calculate partial credit for multiple select', () => {
    // Test implementation
  });

  it('should handle fill blank with fuzzy matching', () => {
    // Test implementation
  });
});
```

---

**Fin de Especificación ET-EDU-005**
