# US-ANA-006: Identificación de Estudiantes Rezagados **Épica:** EAI-004 (Analytics Básico) **Sprint:** Mes 1, Semana 3 **Story Points:** 8 SP **Presupuesto:** $4,200 MXN **Prioridad:** Alta (Alcance Inicial) **Estado:** ✅ Completada (Mes 1) --- ## Descripción Como profesor, quiero identificar fácilmente qué estudiantes están rezagados o en riesgo para poder intervenir a tiempo y brindarles apoyo adicional. **Contexto del Alcance Inicial:** Esta funcionalidad proporciona una vista dedicada de estudiantes que requieren atención, basada en reglas simples (inactividad, bajo progreso). Usa indicadores visuales de semáforo (rojo/amarillo/verde). NO incluye análisis predictivo con ML, alertas automáticas configurables, ni recomendaciones de intervención (eso va a EXT-005 Reportes Avanzados). --- ## Criterios de Aceptación ### CA-01: Definición de Estados de Riesgo - [ ] **Rojo (Crítico):** estudiante sin actividad en >7 días O progreso <30% - [ ] **Amarillo (Advertencia):** estudiante sin actividad en 3-7 días O progreso 30-50% - [ ] **Verde (Activo):** estudiante activo en últimos 3 días Y progreso >50% ### CA-02: Vista de Estudiantes en Riesgo - [ ] Lista filtrable de estudiantes con estado de riesgo - [ ] Filtro rápido: "Solo críticos", "Solo advertencias", "Todos" - [ ] Para cada estudiante muestra: - Avatar y nombre - Estado de riesgo (badge rojo/amarillo/verde) - Días sin actividad - % de progreso - Último módulo accedido - Botón de acción rápida ### CA-03: Indicadores de Riesgo - [ ] Contador de estudiantes en estado crítico (rojo) - [ ] Contador de estudiantes en advertencia (amarillo) - [ ] Contador de estudiantes activos (verde) - [ ] % de la clase en cada categoría ### CA-04: Detalles de Riesgo - [ ] Al hacer clic en estudiante, muestra panel con: - Última actividad (nombre y fecha) - Módulos sin iniciar - Módulos iniciados pero no completados - Comparativa con promedio de la clase - [ ] Botón para ir a perfil completo del estudiante (US-ANA-003) ### CA-05: Ordenamiento - [ ] Ordenar por: estado de riesgo (default), días sin actividad, progreso - [ ] Los estudiantes en riesgo crítico siempre aparecen primero ### CA-06: Acciones Rápidas (UI Placeholder) - [ ] Botón "Enviar Mensaje" (placeholder - funcionalidad en EXT-001) - [ ] Botón "Asignar Actividad" (placeholder - funcionalidad en EXT-001) - [ ] Tooltip indicando que estarán disponibles en extensión futura --- ## Especificaciones Técnicas ### Backend **Endpoint Principal:** ``` GET /api/teacher/classroom/{classroomId}/at-risk-students Query params: ?filter=critical|warning|all ``` **Response:** ```json { "classroomId": "uuid", "summary": { "critical": 5, "warning": 8, "active": 12, "total": 25, "percentages": { "critical": 20, "warning": 32, "active": 48 } }, "students": [ { "id": "student-uuid", "name": "Juan Pérez", "avatarUrl": "/avatars/student.png", "riskLevel": "critical", "riskFactors": { "daysInactive": 10, "progressPercentage": 25, "modulesNotStarted": 5, "modulesIncomplete": 2 }, "lastActivity": { "name": "Suma de fracciones", "moduleName": "Fracciones", "timestamp": "2025-10-23T14:30:00Z" }, "comparison": { "progressDiffFromAverage": -40.5 } }, { "id": "student-uuid-2", "name": "María García", "avatarUrl": "/avatars/student2.png", "riskLevel": "warning", "riskFactors": { "daysInactive": 4, "progressPercentage": 45, "modulesNotStarted": 2, "modulesIncomplete": 3 }, "lastActivity": { "name": "Ángulos rectos", "moduleName": "Geometría", "timestamp": "2025-10-29T16:45:00Z" }, "comparison": { "progressDiffFromAverage": -20.5 } } ] } ``` **Controller:** ```typescript // TeacherAnalyticsController.ts @Get('classroom/:classroomId/at-risk-students') async getAtRiskStudents( @Param('classroomId') classroomId: string, @Query('filter') filter: string = 'all', @CurrentUser() teacher: User ) { return this.analyticsService.getAtRiskStudents( classroomId, teacher.id, filter ); } ``` **Service:** ```typescript // TeacherAnalyticsService.ts async getAtRiskStudents( classroomId: string, teacherId: string, filter: string ) { // Validar acceso await this.validateTeacherAccess(classroomId, teacherId); // Obtener todos los estudiantes de la clase const students = await this.classroomService.getStudents(classroomId); // Calcular progreso promedio de la clase const classAverage = await this.calculateClassAverageProgress(classroomId); // Analizar cada estudiante const analyzedStudents = await Promise.all( students.map(async (student) => { return await this.analyzeStudentRisk(student, classroomId, classAverage); }) ); // Aplicar filtro let filteredStudents = analyzedStudents; if (filter === 'critical') { filteredStudents = analyzedStudents.filter(s => s.riskLevel === 'critical'); } else if (filter === 'warning') { filteredStudents = analyzedStudents.filter(s => s.riskLevel === 'warning'); } // Ordenar: críticos primero, luego por días inactivos filteredStudents.sort((a, b) => { const riskOrder = { critical: 0, warning: 1, active: 2 }; if (riskOrder[a.riskLevel] !== riskOrder[b.riskLevel]) { return riskOrder[a.riskLevel] - riskOrder[b.riskLevel]; } return b.riskFactors.daysInactive - a.riskFactors.daysInactive; }); // Calcular resumen const summary = this.calculateRiskSummary(analyzedStudents); return { classroomId, summary, students: filteredStudents }; } private async analyzeStudentRisk( student: Student, classroomId: string, classAverage: number ) { // Calcular días sin actividad const lastActivityDate = await this.getStudentLastActivityDate(student.id); const daysInactive = lastActivityDate ? differenceInDays(new Date(), new Date(lastActivityDate)) : 999; // Calcular progreso const progress = await this.getStudentProgress(student.id, classroomId); // Determinar nivel de riesgo según reglas const riskLevel = this.calculateRiskLevel(daysInactive, progress.percentage); // Obtener última actividad const lastActivity = await this.getStudentLastActivity(student.id); // Contar módulos const modulesNotStarted = await this.countModulesNotStarted( student.id, classroomId ); const modulesIncomplete = await this.countModulesIncomplete( student.id, classroomId ); return { id: student.id, name: student.name, avatarUrl: student.avatarUrl, riskLevel, riskFactors: { daysInactive, progressPercentage: progress.percentage, modulesNotStarted, modulesIncomplete }, lastActivity, comparison: { progressDiffFromAverage: progress.percentage - classAverage } }; } private calculateRiskLevel(daysInactive: number, progress: number): string { // Crítico: >7 días sin actividad O progreso <30% if (daysInactive > 7 || progress < 30) { return 'critical'; } // Advertencia: 3-7 días sin actividad O progreso 30-50% if ((daysInactive >= 3 && daysInactive <= 7) || (progress >= 30 && progress <= 50)) { return 'warning'; } // Activo return 'active'; } private calculateRiskSummary(students) { const critical = students.filter(s => s.riskLevel === 'critical').length; const warning = students.filter(s => s.riskLevel === 'warning').length; const active = students.filter(s => s.riskLevel === 'active').length; const total = students.length; return { critical, warning, active, total, percentages: { critical: total > 0 ? Math.round((critical / total) * 100) : 0, warning: total > 0 ? Math.round((warning / total) * 100) : 0, active: total > 0 ? Math.round((active / total) * 100) : 0 } }; } ``` ### Frontend **Ruta:** ``` /teacher/classroom/:classroomId/at-risk ``` **Componente Principal:** ```typescript // AtRiskStudentsView.tsx export const AtRiskStudentsView = () => { const { classroomId } = useParams(); const [filter, setFilter] = useState('all'); const { data, isLoading, refetch } = useAtRiskStudents(classroomId, filter); const [selectedStudent, setSelectedStudent] = useState(null); if (isLoading) return ; return (
{selectedStudent && ( setSelectedStudent(null)} /> )}
); }; ``` **Componente de Resumen:** ```typescript // RiskSummary.tsx export const RiskSummary = ({ summary }) => { return (

Estudiantes en Riesgo

} /> } /> } />
{summary.critical > 0 && ( {summary.critical} estudiante{summary.critical > 1 ? 's' : ''} en estado crítico. Requieren atención inmediata. )}
); }; ``` **Componente de Filtros:** ```typescript // FilterBar.tsx export const FilterBar = ({ filter, onFilterChange, summary }) => { return (
onFilterChange('all')} count={summary.total} > Todos onFilterChange('critical')} count={summary.critical} color="red" > Solo Críticos onFilterChange('warning')} count={summary.warning} color="yellow" > Solo Advertencias