# 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
{student.lastActivity.name}
{student.lastActivity.moduleName} •{' '} {formatRelativeTime(student.lastActivity.timestamp)}
> ) : (Sin actividad registrada
)}{student.riskFactors.modulesNotStarted} módulo(s) sin iniciar
{student.riskFactors.modulesIncomplete} módulo(s) incompleto(s)