# ET-EDU-002: Especificación Técnica - Niveles de Dificultad **ID:** ET-EDU-002 **Título:** Implementación del Sistema de 8 Niveles de Dificultad **Módulo:** 03-contenido-educativo **Tipo:** Especificación Técnica **Estado:** ✅ Implementado **Prioridad:** Alta ⭐⭐⭐⭐ **Versión:** 1.1 **Última actualización:** 2025-11-11 **Cambios v1.1:** Migración de ENUM difficulty_level a archivo dedicado (Política de Carga Limpia) --- ## 📋 Resumen Ejecutivo Esta especificación técnica define la implementación completa del sistema de 8 niveles de dificultad progresiva (Beginner → Native) en la plataforma Gamilit. Incluye: - Schema de base de datos con ENUMs, tablas, funciones y políticas RLS - Backend (NestJS) con servicios, controllers, DTOs y guards - Frontend (React) con componentes, hooks y UI - Sistema de promoción automática de nivel - Placement tests para ubicación inicial - Analytics de progresión por nivel --- ## 🔗 Referencias **Implementa:** - [RF-EDU-002: Niveles de Dificultad](../../01-requerimientos/03-contenido-educativo/RF-EDU-002-niveles-dificultad.md) **Relacionado con:** - [ET-EDU-001: Estructura de Ejercicios](./ET-EDU-001-estructura-ejercicios.md) - Ejercicios base - [ET-EDU-003: Taxonomía de Bloom](./ET-EDU-003-taxonomia-bloom.md) - Dimensión cognitiva complementaria - [ET-PRG-001: Sistema de Progreso](../04-progreso-seguimiento/ET-PRG-001-sistema-progreso.md) - Tracking de avance --- ## 🗄️ 1. Base de Datos (PostgreSQL) ### 1.1 ENUM: `difficulty_level` ```sql -- Archivo: apps/database/ddl/schemas/educational_content/enums/difficulty_level.sql CREATE TYPE educational_content.difficulty_level AS ENUM ( 'beginner', -- A1: 10-50 palabras 'elementary', -- A2: 50-150 palabras 'pre_intermediate', -- B1: 150-400 palabras 'intermediate', -- B2: 400-800 palabras 'upper_intermediate', -- C1: 800-1500 palabras 'advanced', -- C2: 1500-3000 palabras 'proficient', -- C2+: 3000+ palabras 'native' -- Vocabulario nativo ); COMMENT ON TYPE educational_content.difficulty_level IS 'Los 8 niveles de dificultad progresiva basados en CEFR (A1-C2+)'; ``` ### 1.2 Tabla: `difficulty_criteria` Define los criterios específicos de cada nivel. ```sql -- Archivo: apps/database/ddl/schemas/educational_content/tables/difficulty_criteria.sql CREATE TABLE educational_content.difficulty_criteria ( level educational_content.difficulty_level PRIMARY KEY, -- Criterios de complejidad vocab_range_min INT NOT NULL, vocab_range_max INT, sentence_length_min INT NOT NULL, sentence_length_max INT, time_multiplier NUMERIC(3,2) NOT NULL DEFAULT 1.0, -- Recompensas base_xp INT NOT NULL DEFAULT 10, base_coins INT NOT NULL DEFAULT 5, -- Criterios de promoción promotion_success_rate NUMERIC(5,2) NOT NULL DEFAULT 80.00, promotion_min_exercises INT NOT NULL DEFAULT 30, promotion_time_threshold NUMERIC(3,2) NOT NULL DEFAULT 1.50, -- Metadatos cefr_level VARCHAR(5) NOT NULL, description TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Insertar criterios para los 8 niveles INSERT INTO educational_content.difficulty_criteria (level, vocab_range_min, vocab_range_max, sentence_length_min, sentence_length_max, time_multiplier, base_xp, base_coins, promotion_success_rate, promotion_min_exercises, promotion_time_threshold, cefr_level, description) VALUES ('beginner', 10, 50, 3, 5, 3.00, 10, 5, 80.00, 30, 1.50, 'A1', 'Nivel principiante: vocabulario básico de supervivencia, saludos, familia, números'), ('elementary', 50, 150, 5, 8, 2.50, 15, 7, 80.00, 30, 1.50, 'A2', 'Nivel elemental: vocabulario cotidiano, presente/pasado simple, descripciones básicas'), ('pre_intermediate', 150, 400, 8, 12, 2.00, 20, 10, 80.00, 30, 1.50, 'B1', 'Pre-intermedio: conversaciones básicas, experiencias personales, expresar opiniones simples'), ('intermediate', 400, 800, 12, 18, 1.50, 30, 15, 80.00, 30, 1.50, 'B2', 'Intermedio: discusiones abstractas, argumentación, textos técnicos básicos'), ('upper_intermediate', 800, 1500, 18, 25, 1.25, 40, 20, 80.00, 30, 1.50, 'C1', 'Intermedio avanzado: lenguaje fluido, matices, textos complejos, uso implícito'), ('advanced', 1500, 3000, 25, 40, 1.00, 50, 25, 80.00, 30, 1.50, 'C2', 'Avanzado: dominio amplio, literatura, expresiones idiomáticas, registro formal/informal'), ('proficient', 3000, NULL, 40, NULL, 1.00, 70, 35, 80.00, 30, 1.50, 'C2+', 'Competente: vocabulario especializado, contextos académicos, textos literarios complejos'), ('native', 3000, NULL, 40, NULL, 0.80, 100, 50, 80.00, 30, 1.50, 'Nativo', 'Nativo: dominio total del idioma, incluyendo modismos regionales y variantes dialectales'); -- Índices CREATE INDEX idx_difficulty_criteria_cefr ON educational_content.difficulty_criteria(cefr_level); -- Trigger para updated_at CREATE TRIGGER update_difficulty_criteria_updated_at BEFORE UPDATE ON educational_content.difficulty_criteria FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); ``` ### 1.3 Tabla: `user_difficulty_progress` Tracking del progreso de cada usuario por nivel. ```sql -- Archivo: apps/database/ddl/schemas/progress_tracking/tables/user_difficulty_progress.sql CREATE TABLE progress_tracking.user_difficulty_progress ( user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, difficulty_level educational_content.difficulty_level NOT NULL, -- Métricas de desempeño exercises_attempted INT NOT NULL DEFAULT 0, exercises_completed INT NOT NULL DEFAULT 0, exercises_correct_first_attempt INT NOT NULL DEFAULT 0, -- Tasa de éxito (calculada automáticamente) success_rate NUMERIC(5,2) GENERATED ALWAYS AS ( CASE WHEN exercises_attempted > 0 THEN ROUND((exercises_correct_first_attempt::NUMERIC / exercises_attempted) * 100, 2) ELSE 0 END ) STORED, -- Tiempo promedio total_time_spent_seconds BIGINT NOT NULL DEFAULT 0, avg_time_per_exercise NUMERIC(10,2) GENERATED ALWAYS AS ( CASE WHEN exercises_completed > 0 THEN ROUND(total_time_spent_seconds::NUMERIC / exercises_completed, 2) ELSE 0 END ) STORED, -- Estado de promoción is_ready_for_promotion BOOLEAN NOT NULL DEFAULT FALSE, promoted_at TIMESTAMP WITH TIME ZONE, -- Timestamps first_attempt_at TIMESTAMP WITH TIME ZONE, last_attempt_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, difficulty_level) ); -- Índices para búsquedas y analytics CREATE INDEX idx_user_difficulty_progress_user ON progress_tracking.user_difficulty_progress(user_id); CREATE INDEX idx_user_difficulty_progress_level ON progress_tracking.user_difficulty_progress(difficulty_level); CREATE INDEX idx_user_difficulty_progress_success_rate ON progress_tracking.user_difficulty_progress(difficulty_level, success_rate DESC); CREATE INDEX idx_user_difficulty_progress_ready_promotion ON progress_tracking.user_difficulty_progress(user_id, is_ready_for_promotion) WHERE is_ready_for_promotion = TRUE; -- Política RLS ALTER TABLE progress_tracking.user_difficulty_progress ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view own progress" ON progress_tracking.user_difficulty_progress FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Teachers can view students progress" ON progress_tracking.user_difficulty_progress FOR SELECT USING ( EXISTS ( SELECT 1 FROM auth.user_roles ur WHERE ur.user_id = auth.uid() AND ur.role = 'teacher' ) ); -- Trigger para updated_at CREATE TRIGGER update_user_difficulty_progress_updated_at BEFORE UPDATE ON progress_tracking.user_difficulty_progress FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); ``` ### 1.4 Tabla: `user_current_level` Nivel actual del estudiante (denormalizado para performance). ```sql -- Archivo: apps/database/ddl/schemas/progress_tracking/tables/user_current_level.sql CREATE TABLE progress_tracking.user_current_level ( user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, current_level educational_content.difficulty_level NOT NULL DEFAULT 'beginner', previous_level educational_content.difficulty_level, -- Control de zona de desarrollo próximo max_allowed_level educational_content.difficulty_level NOT NULL DEFAULT 'elementary', -- Placement test placement_test_completed BOOLEAN NOT NULL DEFAULT FALSE, placement_test_score NUMERIC(5,2), placement_test_date TIMESTAMP WITH TIME ZONE, -- Timestamps level_changed_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Índices CREATE INDEX idx_user_current_level_level ON progress_tracking.user_current_level(current_level); CREATE INDEX idx_user_current_level_max_allowed ON progress_tracking.user_current_level(max_allowed_level); -- Política RLS ALTER TABLE progress_tracking.user_current_level ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view own level" ON progress_tracking.user_current_level FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "System can manage levels" ON progress_tracking.user_current_level FOR ALL USING (TRUE) WITH CHECK (TRUE); -- Trigger para updated_at CREATE TRIGGER update_user_current_level_updated_at BEFORE UPDATE ON progress_tracking.user_current_level FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); ``` ### 1.5 Función: `check_difficulty_promotion_eligibility()` Verifica si un usuario es elegible para promoción de nivel. ```sql -- Archivo: apps/database/ddl/schemas/progress_tracking/functions/check_difficulty_promotion_eligibility.sql CREATE OR REPLACE FUNCTION progress_tracking.check_difficulty_promotion_eligibility( p_user_id UUID, p_difficulty_level educational_content.difficulty_level ) RETURNS BOOLEAN AS $$ DECLARE v_user_progress RECORD; v_criteria RECORD; v_time_ratio NUMERIC; v_avg_time_for_level NUMERIC; BEGIN -- Obtener progreso del usuario SELECT * INTO v_user_progress FROM progress_tracking.user_difficulty_progress WHERE user_id = p_user_id AND difficulty_level = p_difficulty_level; -- Si no tiene intentos, no es elegible IF NOT FOUND OR v_user_progress.exercises_attempted < 1 THEN RETURN FALSE; END IF; -- Obtener criterios del nivel SELECT * INTO v_criteria FROM educational_content.difficulty_criteria WHERE level = p_difficulty_level; -- Verificar cantidad mínima de ejercicios IF v_user_progress.exercises_completed < v_criteria.promotion_min_exercises THEN RETURN FALSE; END IF; -- Verificar tasa de éxito IF v_user_progress.success_rate < v_criteria.promotion_success_rate THEN RETURN FALSE; END IF; -- Verificar tiempo promedio -- Calcular el tiempo promedio esperado para este nivel SELECT AVG(udp.avg_time_per_exercise) INTO v_avg_time_for_level FROM progress_tracking.user_difficulty_progress udp WHERE udp.difficulty_level = p_difficulty_level AND udp.exercises_completed >= 10; -- Si hay datos suficientes, verificar que el usuario no toma excesivo tiempo IF v_avg_time_for_level IS NOT NULL AND v_avg_time_for_level > 0 THEN v_time_ratio := v_user_progress.avg_time_per_exercise / v_avg_time_for_level; IF v_time_ratio > v_criteria.promotion_time_threshold THEN RETURN FALSE; END IF; END IF; -- Si pasa todos los criterios, es elegible RETURN TRUE; END; $$ LANGUAGE plpgsql SECURITY DEFINER; COMMENT ON FUNCTION progress_tracking.check_difficulty_promotion_eligibility IS 'Verifica si un usuario cumple los criterios para promoción al siguiente nivel de dificultad'; ``` ### 1.6 Función: `promote_user_difficulty_level()` Promociona al usuario al siguiente nivel. ```sql -- Archivo: apps/database/ddl/schemas/progress_tracking/functions/promote_user_difficulty_level.sql CREATE OR REPLACE FUNCTION progress_tracking.promote_user_difficulty_level( p_user_id UUID, p_from_level educational_content.difficulty_level, p_to_level educational_content.difficulty_level ) RETURNS VOID AS $$ DECLARE v_next_max_allowed educational_content.difficulty_level; BEGIN -- Actualizar current_level UPDATE progress_tracking.user_current_level SET previous_level = p_from_level, current_level = p_to_level, max_allowed_level = CASE -- Permitir 1 nivel por encima (zona de desarrollo próximo) WHEN p_to_level = 'beginner' THEN 'elementary' WHEN p_to_level = 'elementary' THEN 'pre_intermediate' WHEN p_to_level = 'pre_intermediate' THEN 'intermediate' WHEN p_to_level = 'intermediate' THEN 'upper_intermediate' WHEN p_to_level = 'upper_intermediate' THEN 'advanced' WHEN p_to_level = 'advanced' THEN 'proficient' WHEN p_to_level = 'proficient' THEN 'native' WHEN p_to_level = 'native' THEN 'native' END, level_changed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE user_id = p_user_id; -- Marcar el nivel anterior como promocionado UPDATE progress_tracking.user_difficulty_progress SET promoted_at = CURRENT_TIMESTAMP, is_ready_for_promotion = FALSE, updated_at = CURRENT_TIMESTAMP WHERE user_id = p_user_id AND difficulty_level = p_from_level; -- Crear entrada para el nuevo nivel si no existe INSERT INTO progress_tracking.user_difficulty_progress (user_id, difficulty_level, first_attempt_at) VALUES (p_user_id, p_to_level, CURRENT_TIMESTAMP) ON CONFLICT (user_id, difficulty_level) DO NOTHING; -- Crear notificación de promoción INSERT INTO gamification_system.notifications (user_id, type, priority, title, message, metadata) VALUES ( p_user_id, 'level_promotion', 'high', 'Promoción de Nivel! 🎉', format('¡Felicidades! Has sido promocionado a %s. ¡Sigue así!', p_to_level), jsonb_build_object( 'from_level', p_from_level, 'to_level', p_to_level, 'promoted_at', CURRENT_TIMESTAMP ) ); -- Crear achievement si es el primer usuario en alcanzar este nivel hoy -- (esto es opcional, dependiendo de la lógica de achievements) END; $$ LANGUAGE plpgsql SECURITY DEFINER; COMMENT ON FUNCTION progress_tracking.promote_user_difficulty_level IS 'Promociona al usuario al siguiente nivel de dificultad y crea notificación'; ``` ### 1.7 Función: `update_difficulty_progress()` Actualiza el progreso tras completar un ejercicio. ```sql -- Archivo: apps/database/ddl/schemas/progress_tracking/functions/update_difficulty_progress.sql CREATE OR REPLACE FUNCTION progress_tracking.update_difficulty_progress( p_user_id UUID, p_difficulty_level educational_content.difficulty_level, p_is_completed BOOLEAN, p_is_correct_first_attempt BOOLEAN, p_time_spent_seconds INT ) RETURNS VOID AS $$ DECLARE v_is_eligible BOOLEAN; BEGIN -- Upsert del progreso INSERT INTO progress_tracking.user_difficulty_progress (user_id, difficulty_level, exercises_attempted, exercises_completed, exercises_correct_first_attempt, total_time_spent_seconds, first_attempt_at, last_attempt_at) VALUES ( p_user_id, p_difficulty_level, 1, CASE WHEN p_is_completed THEN 1 ELSE 0 END, CASE WHEN p_is_correct_first_attempt THEN 1 ELSE 0 END, p_time_spent_seconds, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) ON CONFLICT (user_id, difficulty_level) DO UPDATE SET exercises_attempted = progress_tracking.user_difficulty_progress.exercises_attempted + 1, exercises_completed = progress_tracking.user_difficulty_progress.exercises_completed + CASE WHEN p_is_completed THEN 1 ELSE 0 END, exercises_correct_first_attempt = progress_tracking.user_difficulty_progress.exercises_correct_first_attempt + CASE WHEN p_is_correct_first_attempt THEN 1 ELSE 0 END, total_time_spent_seconds = progress_tracking.user_difficulty_progress.total_time_spent_seconds + p_time_spent_seconds, last_attempt_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP; -- Verificar si ahora es elegible para promoción v_is_eligible := progress_tracking.check_difficulty_promotion_eligibility(p_user_id, p_difficulty_level); -- Actualizar flag de elegibilidad UPDATE progress_tracking.user_difficulty_progress SET is_ready_for_promotion = v_is_eligible WHERE user_id = p_user_id AND difficulty_level = p_difficulty_level; -- Si es elegible, crear notificación IF v_is_eligible THEN INSERT INTO gamification_system.notifications (user_id, type, priority, title, message, metadata) VALUES ( p_user_id, 'ready_for_promotion', 'medium', '¡Listo para Promoción! 🚀', format('Has completado todos los requisitos para promoción de %s. ¡Continúa practicando!', p_difficulty_level), jsonb_build_object('level', p_difficulty_level, 'ready_at', CURRENT_TIMESTAMP) ) ON CONFLICT (user_id, type, created_at::date) DO NOTHING; -- Evitar duplicados el mismo día END IF; END; $$ LANGUAGE plpgsql SECURITY DEFINER; COMMENT ON FUNCTION progress_tracking.update_difficulty_progress IS 'Actualiza el progreso del usuario en un nivel de dificultad tras completar un ejercicio'; ``` ### 1.8 Tabla: `placement_tests` Placement tests para ubicación inicial. ```sql -- Archivo: apps/database/ddl/schemas/educational_content/tables/placement_tests.sql CREATE TABLE educational_content.placement_tests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(200) NOT NULL, description TEXT, -- Configuración total_questions INT NOT NULL DEFAULT 30, time_limit_minutes INT NOT NULL DEFAULT 45, -- Scoring scoring_config JSONB NOT NULL DEFAULT '{ "beginner": {"min": 0, "max": 20}, "elementary": {"min": 21, "max": 35}, "pre_intermediate": {"min": 36, "max": 50}, "intermediate": {"min": 51, "max": 65}, "upper_intermediate": {"min": 66, "max": 75}, "advanced": {"min": 76, "max": 85}, "proficient": {"min": 86, "max": 95}, "native": {"min": 96, "max": 100} }'::jsonb, -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_placement_tests_active ON educational_content.placement_tests(is_active) WHERE is_active = TRUE; -- Trigger para updated_at CREATE TRIGGER update_placement_tests_updated_at BEFORE UPDATE ON educational_content.placement_tests FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); ``` ### 1.9 Tabla: `placement_test_results` Resultados de placement tests. ```sql -- Archivo: apps/database/ddl/schemas/progress_tracking/tables/placement_test_results.sql CREATE TABLE progress_tracking.placement_test_results ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, test_id UUID NOT NULL REFERENCES educational_content.placement_tests(id) ON DELETE CASCADE, -- Resultados score NUMERIC(5,2) NOT NULL, -- 0-100 determined_level educational_content.difficulty_level NOT NULL, -- Métricas time_spent_seconds INT NOT NULL, questions_answered INT NOT NULL, questions_correct INT NOT NULL, -- Detalles answers JSONB NOT NULL DEFAULT '[]'::jsonb, -- Timestamps started_at TIMESTAMP WITH TIME ZONE NOT NULL, completed_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, UNIQUE (user_id, test_id, completed_at) ); CREATE INDEX idx_placement_test_results_user ON progress_tracking.placement_test_results(user_id); CREATE INDEX idx_placement_test_results_level ON progress_tracking.placement_test_results(determined_level); -- Política RLS ALTER TABLE progress_tracking.placement_test_results ENABLE ROW LEVEL SECURITY; CREATE POLICY "Users can view own results" ON progress_tracking.placement_test_results FOR SELECT USING (auth.uid() = user_id); ``` --- ## 🖥️ 2. Backend (NestJS + TypeScript) ### 2.1 Enum: `DifficultyLevel` ```typescript // Archivo: apps/backend/src/modules/educational-content/enums/difficulty-level.enum.ts export enum DifficultyLevel { BEGINNER = 'beginner', ELEMENTARY = 'elementary', PRE_INTERMEDIATE = 'pre_intermediate', INTERMEDIATE = 'intermediate', UPPER_INTERMEDIATE = 'upper_intermediate', ADVANCED = 'advanced', PROFICIENT = 'proficient', NATIVE = 'native' } export const DIFFICULTY_ORDER: DifficultyLevel[] = [ DifficultyLevel.BEGINNER, DifficultyLevel.ELEMENTARY, DifficultyLevel.PRE_INTERMEDIATE, DifficultyLevel.INTERMEDIATE, DifficultyLevel.UPPER_INTERMEDIATE, DifficultyLevel.ADVANCED, DifficultyLevel.PROFICIENT, DifficultyLevel.NATIVE ]; export function getNextLevel(currentLevel: DifficultyLevel): DifficultyLevel | null { const currentIndex = DIFFICULTY_ORDER.indexOf(currentLevel); if (currentIndex === -1 || currentIndex === DIFFICULTY_ORDER.length - 1) { return null; // Ya está en el nivel más alto } return DIFFICULTY_ORDER[currentIndex + 1]; } export function getPreviousLevel(currentLevel: DifficultyLevel): DifficultyLevel | null { const currentIndex = DIFFICULTY_ORDER.indexOf(currentLevel); if (currentIndex <= 0) { return null; // Ya está en el nivel más bajo } return DIFFICULTY_ORDER[currentIndex - 1]; } ``` ### 2.2 Entity: `DifficultyProgress` ```typescript // Archivo: apps/backend/src/modules/progress/entities/difficulty-progress.entity.ts import { Entity, Column, PrimaryColumn, Generated, Index } from 'typeorm'; import { DifficultyLevel } from '../../educational-content/enums/difficulty-level.enum'; @Entity('user_difficulty_progress', { schema: 'progress_tracking' }) @Index(['user_id', 'difficulty_level'], { unique: true }) export class DifficultyProgress { @PrimaryColumn('uuid') user_id: string; @PrimaryColumn({ type: 'enum', enum: DifficultyLevel }) difficulty_level: DifficultyLevel; @Column({ type: 'int', default: 0 }) exercises_attempted: number; @Column({ type: 'int', default: 0 }) exercises_completed: number; @Column({ type: 'int', default: 0 }) exercises_correct_first_attempt: number; @Column({ type: 'decimal', precision: 5, scale: 2, select: false }) success_rate: number; @Column({ type: 'bigint', default: 0 }) total_time_spent_seconds: number; @Column({ type: 'decimal', precision: 10, scale: 2, select: false }) avg_time_per_exercise: number; @Column({ type: 'boolean', default: false }) is_ready_for_promotion: boolean; @Column({ type: 'timestamp with time zone', nullable: true }) promoted_at: Date; @Column({ type: 'timestamp with time zone', nullable: true }) first_attempt_at: Date; @Column({ type: 'timestamp with time zone', nullable: true }) last_attempt_at: Date; @Column({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) created_at: Date; @Column({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) updated_at: Date; } ``` ### 2.3 Service: `DifficultyProgressService` ```typescript // Archivo: apps/backend/src/modules/progress/services/difficulty-progress.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { DifficultyProgress } from '../entities/difficulty-progress.entity'; import { DifficultyLevel, getNextLevel } from '../../educational-content/enums/difficulty-level.enum'; @Injectable() export class DifficultyProgressService { constructor( @InjectRepository(DifficultyProgress) private progressRepo: Repository, private dataSource: DataSource ) {} /** * Obtiene el progreso del usuario en todos los niveles */ async getUserProgress(userId: string): Promise { return this.progressRepo.find({ where: { user_id: userId }, order: { difficulty_level: 'ASC' } }); } /** * Obtiene el progreso del usuario en un nivel específico */ async getUserProgressByLevel( userId: string, level: DifficultyLevel ): Promise { return this.progressRepo.findOne({ where: { user_id: userId, difficulty_level: level } }); } /** * Actualiza el progreso tras completar un ejercicio */ async updateProgress( userId: string, level: DifficultyLevel, isCompleted: boolean, isCorrectFirstAttempt: boolean, timeSpentSeconds: number ): Promise { await this.dataSource.query( 'SELECT progress_tracking.update_difficulty_progress($1, $2, $3, $4, $5)', [userId, level, isCompleted, isCorrectFirstAttempt, timeSpentSeconds] ); } /** * Verifica si el usuario es elegible para promoción */ async checkPromotionEligibility( userId: string, level: DifficultyLevel ): Promise { const result = await this.dataSource.query( 'SELECT progress_tracking.check_difficulty_promotion_eligibility($1, $2) AS is_eligible', [userId, level] ); return result[0]?.is_eligible || false; } /** * Promociona al usuario al siguiente nivel */ async promoteUser(userId: string, fromLevel: DifficultyLevel): Promise { const toLevel = getNextLevel(fromLevel); if (!toLevel) { throw new Error(`Cannot promote from ${fromLevel}: already at highest level`); } const isEligible = await this.checkPromotionEligibility(userId, fromLevel); if (!isEligible) { throw new Error(`User ${userId} is not eligible for promotion from ${fromLevel}`); } await this.dataSource.query( 'SELECT progress_tracking.promote_user_difficulty_level($1, $2, $3)', [userId, fromLevel, toLevel] ); } /** * Obtiene usuarios listos para promoción */ async getUsersReadyForPromotion(level?: DifficultyLevel): Promise { const query = this.progressRepo.createQueryBuilder('dp') .where('dp.is_ready_for_promotion = true') .andWhere('dp.promoted_at IS NULL'); if (level) { query.andWhere('dp.difficulty_level = :level', { level }); } return query.getMany(); } /** * Obtiene estadísticas agregadas por nivel */ async getLevelStatistics(level: DifficultyLevel): Promise { const result = await this.dataSource.query(` SELECT COUNT(DISTINCT user_id) AS total_users, ROUND(AVG(success_rate), 2) AS avg_success_rate, ROUND(AVG(avg_time_per_exercise), 2) AS avg_time_per_exercise, COUNT(CASE WHEN is_ready_for_promotion THEN 1 END) AS users_ready_promotion FROM progress_tracking.user_difficulty_progress WHERE difficulty_level = $1 AND exercises_attempted >= 10 `, [level]); return result[0]; } } ``` ### 2.4 Service: `PlacementTestService` ```typescript // Archivo: apps/backend/src/modules/educational-content/services/placement-test.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PlacementTest } from '../entities/placement-test.entity'; import { PlacementTestResult } from '../entities/placement-test-result.entity'; import { DifficultyLevel } from '../enums/difficulty-level.enum'; @Injectable() export class PlacementTestService { constructor( @InjectRepository(PlacementTest) private testRepo: Repository, @InjectRepository(PlacementTestResult) private resultRepo: Repository ) {} /** * Obtiene el placement test activo */ async getActiveTest(): Promise { const test = await this.testRepo.findOne({ where: { is_active: true } }); if (!test) { throw new Error('No active placement test found'); } return test; } /** * Calcula el nivel basado en el puntaje */ determineLevelFromScore(score: number, scoringConfig: any): DifficultyLevel { for (const [level, range] of Object.entries(scoringConfig)) { const { min, max } = range as { min: number; max: number }; if (score >= min && score <= max) { return level as DifficultyLevel; } } return DifficultyLevel.BEGINNER; // Fallback } /** * Guarda los resultados del placement test */ async saveTestResult( userId: string, testId: string, score: number, answers: any[], timeSpentSeconds: number ): Promise { const test = await this.testRepo.findOne({ where: { id: testId } }); if (!test) { throw new Error('Test not found'); } const determinedLevel = this.determineLevelFromScore(score, test.scoring_config); const questionsCorrect = answers.filter(a => a.is_correct).length; const result = this.resultRepo.create({ user_id: userId, test_id: testId, score, determined_level: determinedLevel, time_spent_seconds: timeSpentSeconds, questions_answered: answers.length, questions_correct: questionsCorrect, answers, started_at: new Date(Date.now() - timeSpentSeconds * 1000), completed_at: new Date() }); return this.resultRepo.save(result); } /** * Obtiene el último resultado de placement test del usuario */ async getUserLatestResult(userId: string): Promise { return this.resultRepo.findOne({ where: { user_id: userId }, order: { completed_at: 'DESC' } }); } } ``` ### 2.5 Controller: `DifficultyProgressController` ```typescript // Archivo: apps/backend/src/modules/progress/controllers/difficulty-progress.controller.ts import { Controller, Get, Post, Param, UseGuards, Query } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../../auth/decorators/current-user.decorator'; import { DifficultyProgressService } from '../services/difficulty-progress.service'; import { DifficultyLevel } from '../../educational-content/enums/difficulty-level.enum'; @Controller('progress/difficulty') @UseGuards(JwtAuthGuard) export class DifficultyProgressController { constructor(private progressService: DifficultyProgressService) {} @Get('me') async getMyProgress(@CurrentUser() user: any) { return this.progressService.getUserProgress(user.userId); } @Get('me/:level') async getMyProgressByLevel( @CurrentUser() user: any, @Param('level') level: DifficultyLevel ) { return this.progressService.getUserProgressByLevel(user.userId, level); } @Post('promote/:level') async promoteMe( @CurrentUser() user: any, @Param('level') level: DifficultyLevel ) { await this.progressService.promoteUser(user.userId, level); return { message: 'Promotion successful', promoted_from: level }; } @Get('statistics/:level') async getLevelStatistics(@Param('level') level: DifficultyLevel) { return this.progressService.getLevelStatistics(level); } } ``` --- ## 🎨 3. Frontend (React + TypeScript) ### 3.1 Hook: `useDifficultyProgress` ```typescript // Archivo: apps/frontend/src/hooks/useDifficultyProgress.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '../lib/api-client'; import { DifficultyLevel } from '../types/difficulty-level'; export interface DifficultyProgressData { difficulty_level: DifficultyLevel; exercises_attempted: number; exercises_completed: number; exercises_correct_first_attempt: number; success_rate: number; avg_time_per_exercise: number; is_ready_for_promotion: boolean; promoted_at: string | null; last_attempt_at: string | null; } export function useDifficultyProgress() { return useQuery({ queryKey: ['difficulty-progress'], queryFn: async () => { const response = await apiClient.get('/progress/difficulty/me'); return response.data; }, staleTime: 2 * 60 * 1000 // 2 minutos }); } export function usePromoteLevel() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (level: DifficultyLevel) => { const response = await apiClient.post(`/progress/difficulty/promote/${level}`); return response.data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['difficulty-progress'] }); queryClient.invalidateQueries({ queryKey: ['current-level'] }); } }); } ``` ### 3.2 Componente: `DifficultyProgressDashboard` ```typescript // Archivo: apps/frontend/src/components/progress/DifficultyProgressDashboard.tsx import React from 'react'; import { useDifficultyProgress, usePromoteLevel } from '../../hooks/useDifficultyProgress'; import { Progress } from '../ui/Progress'; import { Badge } from '../ui/Badge'; import { Button } from '../ui/Button'; const LEVEL_LABELS = { beginner: 'Principiante (A1)', elementary: 'Elemental (A2)', pre_intermediate: 'Pre-Intermedio (B1)', intermediate: 'Intermedio (B2)', upper_intermediate: 'Intermedio Avanzado (C1)', advanced: 'Avanzado (C2)', proficient: 'Competente (C2+)', native: 'Nativo' }; const LEVEL_COLORS = { beginner: 'green', elementary: 'green', pre_intermediate: 'blue', intermediate: 'blue', upper_intermediate: 'purple', advanced: 'purple', proficient: 'orange', native: 'red' }; export function DifficultyProgressDashboard() { const { data: progress, isLoading, error } = useDifficultyProgress(); const promoteMutation = usePromoteLevel(); if (isLoading) return
Cargando progreso...
; if (error) return
Error al cargar progreso
; if (!progress || progress.length === 0) return
No hay datos de progreso
; const handlePromote = (level: string) => { if (confirm(`¿Seguro que quieres promocionar desde ${LEVEL_LABELS[level]}?`)) { promoteMutation.mutate(level as any); } }; return (

Tu Progreso por Nivel de Dificultad

{progress.map(levelProgress => (
{LEVEL_LABELS[levelProgress.difficulty_level]} {levelProgress.is_ready_for_promotion && ( ¡Listo para promoción! 🚀 )}
Tasa de Éxito: {levelProgress.success_rate}%
Ejercicios Completados: {levelProgress.exercises_completed}
Tiempo Promedio: {Math.round(levelProgress.avg_time_per_exercise)}s
{levelProgress.is_ready_for_promotion && ( )} {levelProgress.promoted_at && (
✓ Promocionado el {new Date(levelProgress.promoted_at).toLocaleDateString()}
)}
))}
); } ``` ### 3.3 Componente: `PlacementTestFlow` ```typescript // Archivo: apps/frontend/src/components/placement-test/PlacementTestFlow.tsx import React, { useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { apiClient } from '../../lib/api-client'; import { Button } from '../ui/Button'; export function PlacementTestFlow() { const [testStarted, setTestStarted] = useState(false); const [currentQuestion, setCurrentQuestion] = useState(0); const [answers, setAnswers] = useState([]); const [startTime, setStartTime] = useState(0); const submitTestMutation = useMutation({ mutationFn: async (data: any) => { const response = await apiClient.post('/placement-test/submit', data); return response.data; }, onSuccess: (data) => { alert(`¡Test completado! Tu nivel es: ${data.determined_level}`); // Redirigir al dashboard } }); const handleStartTest = () => { setTestStarted(true); setStartTime(Date.now()); }; const handleSubmitTest = () => { const timeSpentSeconds = Math.floor((Date.now() - startTime) / 1000); const score = (answers.filter(a => a.is_correct).length / answers.length) * 100; submitTestMutation.mutate({ test_id: 'test-id', // TODO: Obtener del API score, answers, time_spent_seconds: timeSpentSeconds }); }; if (!testStarted) { return (

Test de Ubicación

Este test determinará tu nivel inicial de maya yucateco.

  • Duración: 45 minutos
  • Preguntas: 30
  • Una vez iniciado, no puedes pausar
); } return (
Pregunta {currentQuestion + 1} de 30
{/* TODO: Renderizar pregunta actual */} {currentQuestion === 29 && ( )}
); } ``` --- ## 🧪 4. Tests ### 4.1 Test: `difficulty-progress.service.spec.ts` ```typescript // Archivo: apps/backend/src/modules/progress/services/difficulty-progress.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { DifficultyProgressService } from './difficulty-progress.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DifficultyProgress } from '../entities/difficulty-progress.entity'; import { DataSource } from 'typeorm'; import { DifficultyLevel } from '../../educational-content/enums/difficulty-level.enum'; describe('DifficultyProgressService', () => { let service: DifficultyProgressService; let mockProgressRepo: any; let mockDataSource: any; beforeEach(async () => { mockProgressRepo = { find: jest.fn(), findOne: jest.fn(), save: jest.fn() }; mockDataSource = { query: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ providers: [ DifficultyProgressService, { provide: getRepositoryToken(DifficultyProgress), useValue: mockProgressRepo }, { provide: DataSource, useValue: mockDataSource } ] }).compile(); service = module.get(DifficultyProgressService); }); describe('getUserProgress', () => { it('should return user progress for all levels', async () => { const userId = 'user-123'; const mockProgress = [ { user_id: userId, difficulty_level: DifficultyLevel.BEGINNER, success_rate: 85 }, { user_id: userId, difficulty_level: DifficultyLevel.ELEMENTARY, success_rate: 75 } ]; mockProgressRepo.find.mockResolvedValue(mockProgress); const result = await service.getUserProgress(userId); expect(result).toEqual(mockProgress); expect(mockProgressRepo.find).toHaveBeenCalledWith({ where: { user_id: userId }, order: { difficulty_level: 'ASC' } }); }); }); describe('checkPromotionEligibility', () => { it('should return true when user is eligible for promotion', async () => { mockDataSource.query.mockResolvedValue([{ is_eligible: true }]); const result = await service.checkPromotionEligibility('user-123', DifficultyLevel.BEGINNER); expect(result).toBe(true); expect(mockDataSource.query).toHaveBeenCalledWith( expect.stringContaining('check_difficulty_promotion_eligibility'), ['user-123', DifficultyLevel.BEGINNER] ); }); it('should return false when user is not eligible', async () => { mockDataSource.query.mockResolvedValue([{ is_eligible: false }]); const result = await service.checkPromotionEligibility('user-123', DifficultyLevel.BEGINNER); expect(result).toBe(false); }); }); describe('promoteUser', () => { it('should promote user to next level when eligible', async () => { mockDataSource.query .mockResolvedValueOnce([{ is_eligible: true }]) // check eligibility .mockResolvedValueOnce([]); // promote await service.promoteUser('user-123', DifficultyLevel.BEGINNER); expect(mockDataSource.query).toHaveBeenCalledTimes(2); expect(mockDataSource.query).toHaveBeenLastCalledWith( expect.stringContaining('promote_user_difficulty_level'), ['user-123', DifficultyLevel.BEGINNER, DifficultyLevel.ELEMENTARY] ); }); it('should throw error when user is not eligible', async () => { mockDataSource.query.mockResolvedValue([{ is_eligible: false }]); await expect( service.promoteUser('user-123', DifficultyLevel.BEGINNER) ).rejects.toThrow('not eligible for promotion'); }); it('should throw error when already at highest level', async () => { await expect( service.promoteUser('user-123', DifficultyLevel.NATIVE) ).rejects.toThrow('already at highest level'); }); }); }); ``` --- ## 📊 5. Analytics y Reportes ### 5.1 Vista Materializada: `difficulty_level_stats` ```sql -- Archivo: apps/database/ddl/schemas/progress_tracking/materialized-views/difficulty_level_stats.sql CREATE MATERIALIZED VIEW progress_tracking.difficulty_level_stats AS SELECT udp.difficulty_level, COUNT(DISTINCT udp.user_id) AS total_users, ROUND(AVG(udp.success_rate), 2) AS avg_success_rate, ROUND(AVG(udp.avg_time_per_exercise), 2) AS avg_time_per_exercise, COUNT(CASE WHEN udp.is_ready_for_promotion THEN 1 END) AS users_ready_promotion, COUNT(CASE WHEN udp.promoted_at IS NOT NULL THEN 1 END) AS users_promoted, MIN(udp.first_attempt_at) AS earliest_attempt, MAX(udp.last_attempt_at) AS latest_attempt FROM progress_tracking.user_difficulty_progress udp WHERE udp.exercises_attempted >= 5 GROUP BY udp.difficulty_level; -- Índice para búsquedas rápidas CREATE UNIQUE INDEX idx_difficulty_level_stats_level ON progress_tracking.difficulty_level_stats(difficulty_level); -- Refresh programado (diariamente a las 2 AM) CREATE EXTENSION IF NOT EXISTS pg_cron; SELECT cron.schedule('refresh-difficulty-stats', '0 2 * * *', 'REFRESH MATERIALIZED VIEW CONCURRENTLY progress_tracking.difficulty_level_stats'); ``` --- ## ✅ Criterios de Aceptación ### CA-001: ENUMs y Configuración Base - [x] Existe ENUM `difficulty_level` con 8 valores - [x] Tabla `difficulty_criteria` tiene configuración para todos los niveles - [x] Valores CEFR están correctamente mapeados (A1-C2+) ### CA-002: Tracking de Progreso - [x] Tabla `user_difficulty_progress` registra intentos y resultados - [x] Campos calculados (`success_rate`, `avg_time_per_exercise`) funcionan correctamente - [x] Flag `is_ready_for_promotion` se actualiza automáticamente ### CA-003: Promoción de Nivel - [x] Función `check_difficulty_promotion_eligibility()` valida 3 criterios (éxito, cantidad, tiempo) - [x] Función `promote_user_difficulty_level()` promociona correctamente - [x] Se crea notificación al promocionar - [x] `max_allowed_level` respeta zona de desarrollo próximo ### CA-004: Placement Test - [x] Tabla `placement_tests` permite configurar múltiples tests - [x] `scoring_config` JSONB define rangos por nivel - [x] Resultados se guardan en `placement_test_results` - [x] Nivel inicial se asigna basado en puntaje ### CA-005: Backend API - [x] Endpoints GET `/progress/difficulty/me` retorna progreso del usuario - [x] Endpoint POST `/progress/difficulty/promote/:level` promociona al usuario - [x] Service valida elegibilidad antes de promocionar - [x] Errores descriptivos cuando no se cumplen criterios ### CA-006: Frontend UI - [x] Componente `DifficultyProgressDashboard` muestra progreso visual - [x] Se indica con badge cuando usuario está listo para promoción - [x] Botón de promoción funcional con confirmación - [x] Hook `useDifficultyProgress` cachea datos con React Query ### CA-007: Tests - [x] Tests unitarios para `DifficultyProgressService` - [x] Tests de integración para promoción de nivel - [x] Tests de función `check_difficulty_promotion_eligibility()` - [x] Tests de placement test scoring --- ## 📚 Referencias Técnicas ### Database - Schema: `educational_content` - Configuración de niveles - Schema: `progress_tracking` - Tracking de progreso - Función: `check_difficulty_promotion_eligibility()` - Validación de promoción - Función: `promote_user_difficulty_level()` - Promoción automática - Función: `update_difficulty_progress()` - Actualización de progreso ### Backend - Service: `apps/backend/src/modules/progress/services/difficulty-progress.service.ts` - Controller: `apps/backend/src/modules/progress/controllers/difficulty-progress.controller.ts` - Service: `apps/backend/src/modules/educational-content/services/placement-test.service.ts` ### Frontend - Hook: `apps/frontend/src/hooks/useDifficultyProgress.ts` - Component: `apps/frontend/src/components/progress/DifficultyProgressDashboard.tsx` - Component: `apps/frontend/src/components/placement-test/PlacementTestFlow.tsx` ### Documentación Relacionada - [RF-EDU-002: Niveles de Dificultad](../../01-requerimientos/03-contenido-educativo/RF-EDU-002-niveles-dificultad.md) - [ET-EDU-001: Estructura de Ejercicios](./ET-EDU-001-estructura-ejercicios.md) - [ET-EDU-003: Taxonomía de Bloom](./ET-EDU-003-taxonomia-bloom.md) --- **Última revisión:** 2025-11-07 **Revisores:** Equipo Backend, Frontend, Database **Próxima revisión:** 2025-12-07