# US-ANA-005: Tracking de Actividad **Épica:** EAI-004 (Analytics Básico) **Sprint:** Mes 1, Semana 3 **Story Points:** 7 SP **Presupuesto:** $3,400 MXN **Prioridad:** Alta (Alcance Inicial) **Estado:** ✅ Completada (Mes 1) --- ## Descripción Como profesor, quiero ver un timeline de las actividades recientes de mi clase para entender qué estudiantes están activos y qué están haciendo en tiempo casi real. **Contexto del Alcance Inicial:** Esta vista proporciona un feed de actividades de la clase con filtrado básico por fecha. Muestra las últimas 50 actividades en formato timeline. NO incluye filtros avanzados por estudiante/módulo, agrupaciones complejas, ni análisis de patrones de actividad (eso va a EXT-005 Reportes Avanzados). --- ## Criterios de Aceptación ### CA-01: Timeline de Actividades - [ ] Muestra las últimas 50 actividades de la clase - [ ] Para cada actividad muestra: - Avatar del estudiante - Nombre del estudiante - Tipo de actividad (completada, iniciada, logro desbloqueado) - Nombre del módulo - Nombre de la actividad - Timestamp (fecha/hora relativa) - XP ganado (si aplica) - [ ] Actividades ordenadas de más reciente a más antigua ### CA-02: Filtro por Fecha - [ ] Selector de rango de fechas básico: - Hoy - Últimos 7 días (default) - Últimos 30 días - Todo el tiempo - [ ] Timeline se actualiza al cambiar el filtro ### CA-03: Tipos de Actividad - [ ] **Actividad Completada:** estudiante completó una actividad - [ ] **Módulo Iniciado:** estudiante inició un nuevo módulo - [ ] **Nivel Alcanzado:** estudiante subió de nivel - [ ] **Logro Desbloqueado:** estudiante desbloqueó una insignia - [ ] Cada tipo tiene icono y color distintivo ### CA-04: Indicadores de Actividad - [ ] Badge que muestra # de estudiantes activos HOY - [ ] Badge que muestra # de actividades completadas HOY - [ ] Gráfica simple de actividad por día (últimos 7 días) ### CA-05: Auto-Refresh - [ ] Timeline se actualiza automáticamente cada 2 minutos - [ ] Indicador visual de última actualización - [ ] Botón manual de refresh ### CA-06: Performance - [ ] Carga inicial en menos de 1 segundo - [ ] Paginación: cargar más al hacer scroll (load more) - [ ] Skeleton loaders durante carga --- ## Especificaciones Técnicas ### Backend **Endpoint Principal:** ``` GET /api/teacher/classroom/{classroomId}/activity-feed Query params: ?range=7d&limit=50&offset=0 ``` **Response:** ```json { "classroomId": "uuid", "dateRange": "7d", "stats": { "activeStudentsToday": 12, "activitiesCompletedToday": 45, "activityByDay": [ {"date": "2025-11-02", "count": 45}, {"date": "2025-11-01", "count": 38}, {"date": "2025-10-31", "count": 52} ] }, "activities": [ { "id": "activity-log-uuid", "type": "activity_completed", "student": { "id": "student-uuid", "name": "Juan Pérez", "avatarUrl": "/avatars/student.png" }, "module": { "id": "module-uuid", "name": "Fracciones" }, "activity": { "id": "activity-uuid", "name": "Suma de fracciones" }, "timestamp": "2025-11-02T10:30:00Z", "metadata": { "xpEarned": 50, "score": 95 } }, { "id": "activity-log-uuid-2", "type": "level_up", "student": { "id": "student-uuid-2", "name": "María García", "avatarUrl": "/avatars/student2.png" }, "timestamp": "2025-11-02T10:25:00Z", "metadata": { "newLevel": 4, "xpEarned": 100 } }, { "id": "activity-log-uuid-3", "type": "achievement_unlocked", "student": { "id": "student-uuid-3", "name": "Carlos López", "avatarUrl": "/avatars/student3.png" }, "timestamp": "2025-11-02T10:20:00Z", "metadata": { "achievementName": "Maestro de Fracciones", "achievementIcon": "/icons/achievement.png" } } ], "pagination": { "limit": 50, "offset": 0, "hasMore": true } } ``` **Controller:** ```typescript // TeacherAnalyticsController.ts @Get('classroom/:classroomId/activity-feed') async getActivityFeed( @Param('classroomId') classroomId: string, @Query() query: ActivityFeedQueryDto, @CurrentUser() teacher: User ) { return this.analyticsService.getActivityFeed( classroomId, teacher.id, query ); } ``` **DTO:** ```typescript // ActivityFeedQueryDto.ts export class ActivityFeedQueryDto { @IsOptional() @IsIn(['today', '7d', '30d', 'all']) range?: string = '7d'; @IsOptional() @IsInt() @Min(1) @Max(100) limit?: number = 50; @IsOptional() @IsInt() @Min(0) offset?: number = 0; } ``` **Service:** ```typescript // TeacherAnalyticsService.ts async getActivityFeed( classroomId: string, teacherId: string, query: ActivityFeedQueryDto ) { // Validar acceso await this.validateTeacherAccess(classroomId, teacherId); // Calcular rango de fechas const dateRange = this.calculateDateRange(query.range); // Obtener estadísticas del día const stats = await this.getActivityStats(classroomId, dateRange); // Obtener actividades const activities = await this.activityLogRepository .createQueryBuilder('log') .innerJoinAndSelect('log.student', 'student') .leftJoinAndSelect('log.module', 'module') .leftJoinAndSelect('log.activity', 'activity') .where('log.classroomId = :classroomId', { classroomId }) .andWhere('log.timestamp >= :startDate', { startDate: dateRange.start }) .andWhere('log.timestamp <= :endDate', { endDate: dateRange.end }) .orderBy('log.timestamp', 'DESC') .skip(query.offset) .take(query.limit) .getMany(); const hasMore = activities.length === query.limit; return { classroomId, dateRange: query.range, stats, activities: activities.map(log => this.mapActivityLog(log)), pagination: { limit: query.limit, offset: query.offset, hasMore } }; } private calculateDateRange(range: string) { const now = new Date(); let start: Date; switch (range) { case 'today': start = startOfDay(now); break; case '7d': start = subDays(now, 7); break; case '30d': start = subDays(now, 30); break; case 'all': start = new Date(0); // Desde el inicio break; } return { start, end: now }; } private async getActivityStats(classroomId: string, dateRange) { const today = startOfDay(new Date()); // Estudiantes activos hoy const activeStudentsToday = await this.activityLogRepository .createQueryBuilder('log') .select('DISTINCT log.studentId') .where('log.classroomId = :classroomId', { classroomId }) .andWhere('log.timestamp >= :today', { today }) .getCount(); // Actividades completadas hoy const activitiesCompletedToday = await this.activityLogRepository .count({ where: { classroomId, type: 'activity_completed', timestamp: MoreThanOrEqual(today) } }); // Actividad por día (últimos 7 días) const activityByDay = await this.getActivityByDay(classroomId, 7); return { activeStudentsToday, activitiesCompletedToday, activityByDay }; } ``` **Modelo de ActivityLog:** ```typescript // activity-log.entity.ts @Entity('activity_logs') export class ActivityLog { @PrimaryGeneratedColumn('uuid') id: string; @Column() type: string; // activity_completed, module_started, level_up, achievement_unlocked @ManyToOne(() => Student) @JoinColumn({ name: 'student_id' }) student: Student; @Column({ name: 'classroom_id' }) classroomId: string; @ManyToOne(() => Module, { nullable: true }) @JoinColumn({ name: 'module_id' }) module?: Module; @ManyToOne(() => Activity, { nullable: true }) @JoinColumn({ name: 'activity_id' }) activity?: Activity; @Column({ type: 'timestamp' }) timestamp: Date; @Column({ type: 'jsonb', nullable: true }) metadata: any; // xpEarned, score, achievementName, etc. } ``` ### Frontend **Ruta:** ``` /teacher/classroom/:classroomId/activity ``` **Componente Principal:** ```typescript // ActivityFeedView.tsx export const ActivityFeedView = () => { const { classroomId } = useParams(); const [dateRange, setDateRange] = useState('7d'); const [activities, setActivities] = useState([]); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(true); const [isLoading, setIsLoading] = useState(true); const { stats } = useActivityStats(classroomId); useEffect(() => { loadActivities(0); // Reset al cambiar rango }, [dateRange]); // Auto-refresh cada 2 minutos useEffect(() => { const interval = setInterval(() => { loadActivities(0); }, 120000); // 2 minutos return () => clearInterval(interval); }, [dateRange]); const loadActivities = async (newOffset: number) => { setIsLoading(true); const data = await fetchActivityFeed(classroomId, { range: dateRange, offset: newOffset }); if (newOffset === 0) { setActivities(data.activities); } else { setActivities(prev => [...prev, ...data.activities]); } setOffset(newOffset); setHasMore(data.pagination.hasMore); setIsLoading(false); }; const loadMore = () => { if (hasMore && !isLoading) { loadActivities(offset + 50); } }; return (
loadActivities(0)} />
); }; ``` **Componente de Header:** ```typescript // ActivityHeader.tsx export const ActivityHeader = ({ stats, dateRange, onDateRangeChange, onRefresh }) => { return (
{stats?.activeStudentsToday} estudiantes activos hoy {stats?.activitiesCompletedToday} actividades completadas hoy