workspace-v1/projects/gamilit/docs/01-fase-alcance-inicial/EAI-004-analytics/historias-usuario/US-ANA-005-tracking-actividad.md
Adrian Flores Cortes 967ab360bb Initial commit: Workspace v1 with 3-layer architecture
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>
2025-12-23 00:35:19 -06:00

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:

  • calculateDateRange retorna rangos correctos para cada opción
  • getActivityStats cuenta correctamente
  • mapActivityLog formatea 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

  1. Performance:

    • Índice en (classroomId, timestamp) para queries rápidos
    • Limitar a 50 actividades por request
    • Cachear stats por 1 minuto
  2. Logging:

    • Crear ActivityLog en cada evento relevante
    • Background job para limpiar logs antiguos (>90 días)
  3. UX:

    • Highlight de actividad más reciente
    • Smooth scroll al cargar más
    • Indicador visual de auto-refresh
  4. 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