Structure: - control-plane/: Registries, SIMCO directives, CI/CD templates - projects/: Gamilit, ERP-Suite, Trading-Platform, Betting-Analytics - shared/: Libs catalog, knowledge-base Key features: - Centralized port, domain, database, and service registries - 23 SIMCO directives + 6 fundamental principles - NEXUS agent profiles with delegation rules - Validation scripts for workspace integrity - Dockerfiles for all services - Path aliases for quick reference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
18 KiB
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:
{
"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:
// 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:
// 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:
// 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:
// 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:
// 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 (
<div className="activity-feed-container">
<ActivityHeader
stats={stats}
dateRange={dateRange}
onDateRangeChange={setDateRange}
onRefresh={() => loadActivities(0)}
/>
<ActivityChart data={stats?.activityByDay} />
<ActivityTimeline
activities={activities}
isLoading={isLoading}
onLoadMore={loadMore}
hasMore={hasMore}
/>
</div>
);
};
Componente de Header:
// ActivityHeader.tsx
export const ActivityHeader = ({ stats, dateRange, onDateRangeChange, onRefresh }) => {
return (
<div className="activity-header">
<div className="stats-badges">
<Badge color="green" size="lg">
{stats?.activeStudentsToday} estudiantes activos hoy
</Badge>
<Badge color="blue" size="lg">
{stats?.activitiesCompletedToday} actividades completadas hoy
</Badge>
</div>
<div className="controls">
<Select
value={dateRange}
onChange={(e) => onDateRangeChange(e.target.value)}
options={[
{ value: 'today', label: 'Hoy' },
{ value: '7d', label: 'Últimos 7 días' },
{ value: '30d', label: 'Últimos 30 días' },
{ value: 'all', label: 'Todo el tiempo' }
]}
/>
<Button
onClick={onRefresh}
variant="ghost"
leftIcon={<RefreshIcon />}
>
Actualizar
</Button>
</div>
</div>
);
};
Componente de Timeline:
// ActivityTimeline.tsx
export const ActivityTimeline = ({ activities, isLoading, onLoadMore, hasMore }) => {
if (isLoading && activities.length === 0) {
return <TimelineSkeleton />;
}
if (activities.length === 0) {
return <EmptyState message="No hay actividades en este período" />;
}
return (
<div className="activity-timeline">
{activities.map((activity, index) => (
<ActivityItem
key={activity.id}
activity={activity}
isFirst={index === 0}
/>
))}
{hasMore && (
<Button
onClick={onLoadMore}
disabled={isLoading}
fullWidth
variant="outline"
>
{isLoading ? 'Cargando...' : 'Cargar más'}
</Button>
)}
</div>
);
};
Componente de Item:
// ActivityItem.tsx
export const ActivityItem = ({ activity, isFirst }) => {
const config = getActivityConfig(activity.type);
return (
<div className={`activity-item ${isFirst ? 'highlight' : ''}`}>
<div className="activity-icon" style={{ backgroundColor: config.color }}>
{config.icon}
</div>
<div className="activity-content">
<div className="activity-header">
<Avatar src={activity.student.avatarUrl} size="sm" />
<span className="student-name">{activity.student.name}</span>
<span className="activity-time">{formatRelativeTime(activity.timestamp)}</span>
</div>
<div className="activity-description">
{renderActivityDescription(activity)}
</div>
{activity.metadata?.xpEarned && (
<Badge color="gold" size="sm">
+{activity.metadata.xpEarned} XP
</Badge>
)}
</div>
</div>
);
};
function getActivityConfig(type: string) {
const configs = {
activity_completed: {
icon: <CheckIcon />,
color: '#10b981',
label: 'completó'
},
module_started: {
icon: <PlayIcon />,
color: '#3b82f6',
label: 'inició'
},
level_up: {
icon: <TrophyIcon />,
color: '#f59e0b',
label: 'subió a nivel'
},
achievement_unlocked: {
icon: <StarIcon />,
color: '#8b5cf6',
label: 'desbloqueó'
}
};
return configs[type] || configs.activity_completed;
}
function renderActivityDescription(activity) {
switch (activity.type) {
case 'activity_completed':
return (
<>
completó <strong>{activity.activity.name}</strong> en{' '}
<em>{activity.module.name}</em>
{activity.metadata?.score && (
<> con {activity.metadata.score}%</>
)}
</>
);
case 'module_started':
return (
<>
inició el módulo <strong>{activity.module.name}</strong>
</>
);
case 'level_up':
return (
<>
subió a <strong>Nivel {activity.metadata.newLevel}</strong>
</>
);
case 'achievement_unlocked':
return (
<>
desbloqueó el logro <strong>{activity.metadata.achievementName}</strong>
</>
);
default:
return null;
}
}
Diseño UI/UX
Layout Desktop
+-------------------------------------------------------------------+
| 📊 Actividad de la Clase [🔄 Actualizar]|
+-------------------------------------------------------------------+
| [12 estudiantes activos hoy] [45 actividades completadas hoy] |
+-------------------------------------------------------------------+
| Filtro: [Últimos 7 días ▼] |
+-------------------------------------------------------------------+
| [Gráfica de barras simple: actividad por día] |
+-------------------------------------------------------------------+
| TIMELINE |
| +-------------------------------------------------------------+ |
| | ✓ [👤] Juan Pérez completó "Suma de fracciones" | |
| | Fracciones • Hace 5 minutos • +50 XP | |
| +-------------------------------------------------------------+ |
| | 🏆 [👤] María García subió a Nivel 4 | |
| | Hace 10 minutos • +100 XP | |
| +-------------------------------------------------------------+ |
| | ⭐ [👤] Carlos López desbloqueó "Maestro de Fracciones" | |
| | Hace 15 minutos | |
| +-------------------------------------------------------------+ |
| | ▶️ [👤] Ana Martínez inició "Geometría" | |
| | Hace 20 minutos | |
| +-------------------------------------------------------------+ |
| |
| [Cargar más actividades] |
+-------------------------------------------------------------------+
Alcance Básico vs Extensiones
EAI-004 (Este alcance - Analytics Básico):
- ✅ Timeline de últimas 50 actividades
- ✅ Filtro simple por rango de fechas (today, 7d, 30d, all)
- ✅ Auto-refresh cada 2 minutos
- ✅ Estadísticas básicas del día
- ✅ Paginación con "Cargar más"
- ✅ 4 tipos de actividad básicos
EXT-005 (Extensión futura - Reportes Avanzados):
- ⏳ Filtros avanzados (por estudiante, módulo, tipo)
- ⏳ Búsqueda en timeline
- ⏳ Agrupación por estudiante/módulo
- ⏳ Análisis de patrones de actividad
- ⏳ Alertas configurables (ej: estudiante inactivo >X días)
- ⏳ Exportación de timeline a CSV
- ⏳ Vista de heatmap de actividad
- ⏳ Real-time con WebSockets (actualización instantánea)
- ⏳ Comparativa de actividad entre clases
Dependencias
Dependencias Técnicas:
- Backend: Sistema de logging de actividades (ActivityLog entity)
- Backend: Eventos en EAI-002, EAI-003 que crean logs
- Frontend: Infinite scroll / load more pattern
Dependencias de User Stories:
- EAI-002 (actividades que generan logs)
- EAI-003 (gamificación que genera logs de level_up, achievements)
Pruebas
Pruebas Unitarias:
calculateDateRangeretorna rangos correctos para cada opcióngetActivityStatscuenta correctamentemapActivityLogformatea correctamente según tipo- Paginación funciona correctamente
Pruebas de Integración:
- Endpoint retorna actividades del rango correcto
- Filtro por fecha funciona
- Paginación retorna correctamente con hasMore
- Stats se calculan correctamente
Pruebas E2E:
- Profesor ve timeline de actividades
- Filtro por fecha actualiza timeline
- Cargar más funciona correctamente
- Auto-refresh actualiza timeline
- Cada tipo de actividad se muestra correctamente
Notas de Implementación
-
Performance:
- Índice en
(classroomId, timestamp)para queries rápidos - Limitar a 50 actividades por request
- Cachear stats por 1 minuto
- Índice en
-
Logging:
- Crear ActivityLog en cada evento relevante
- Background job para limpiar logs antiguos (>90 días)
-
UX:
- Highlight de actividad más reciente
- Smooth scroll al cargar más
- Indicador visual de auto-refresh
-
Escalabilidad:
- Para clases muy activas, considerar WebSockets en EXT-005
Estimación de Esfuerzo
Backend: 3 SP
- Endpoint de activity feed
- Sistema de logging (si no existe)
- Stats y agregaciones
Frontend: 3 SP
- Timeline con load more
- Auto-refresh
- Múltiples tipos de actividad
Testing: 1 SP
Total: 7 SP = $3,400 MXN