workspace/projects/gamilit/docs/01-fase-alcance-inicial/EAI-003-gamificacion/historias-usuario/US-GAM-005-insignias-basicas.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- Configure workspace Git repository with comprehensive .gitignore
- Add Odoo as submodule for ERP reference code
- Include documentation: SETUP.md, GIT-STRUCTURE.md
- Add gitignore templates for projects (backend, frontend, database)
- Structure supports independent repos per project/subproject level

Workspace includes:
- core/ - Reusable patterns, modules, orchestration system
- projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.)
- knowledge-base/ - Reference code and patterns (includes Odoo submodule)
- devtools/ - Development tools and templates
- customers/ - Client implementations template

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:44:23 -06:00

9.5 KiB

US-GAM-005: Insignias básicas

Épica: EAI-003 - Gamificación Básica Sprint: Mes 1, Semana 3-4 Story Points: 8 SP Presupuesto: $2,900 MXN Prioridad: Alta (Alcance Inicial) Estado: Completada (Mes 1)


Descripción

Como estudiante, quiero ganar insignias por logros específicos para coleccionar reconocimientos visuales de mis avances.

Contexto del Alcance Inicial: 10 insignias pre-definidas que se otorgan automáticamente por logros. Hardcoded sin editor. Simple galería para visualizarlas.


Criterios de Aceptación

  • CA-01: 10 insignias pre-definidas
  • CA-02: Se otorgan automáticamente al cumplir criterio
  • CA-03: Notificación al desbloquear insignia
  • CA-04: Galería de insignias (desbloqueadas/bloqueadas)
  • CA-05: Tooltip muestra cómo desbloquear
  • CA-06: Insignias tienen imagen, nombre, descripción
  • CA-07: Se registra fecha de obtención
  • CA-08: Se muestran en perfil

Especificaciones Técnicas

Backend

@Entity('badges')
class Badge {
  @PrimaryGeneratedColumn('uuid')
  id: string

  @Column()
  name: string

  @Column({ type: 'text' })
  description: string

  @Column()
  imageUrl: string

  @Column({ type: 'enum', enum: BadgeType })
  type: BadgeType

  @Column({ type: 'jsonb' })
  criteria: BadgeCriteria // Criterios para desbloquear

  @Column({ type: 'int', default: 0 })
  xpReward: number

  @Column({ type: 'int', default: 0 })
  coinsReward: number
}

enum BadgeType {
  FIRST_STEPS = 'first_steps',
  MODULE_COMPLETION = 'module_completion',
  STREAK = 'streak',
  XP_MILESTONE = 'xp_milestone',
  RANK_UP = 'rank_up'
}

interface BadgeCriteria {
  type: 'first_activity' | 'complete_module' | 'reach_xp' | 'streak_days' | 'rank_achieved'
  value?: any // Depende del tipo
}

@Entity('user_badges')
class UserBadge {
  @PrimaryGeneratedColumn('uuid')
  id: string

  @ManyToOne(() => User)
  user: User

  @Column()
  userId: string

  @ManyToOne(() => Badge)
  badge: Badge

  @Column()
  badgeId: string

  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  earnedAt: Date
}

// Seed data de insignias
const BADGES_SEED = [
  {
    name: 'Primer Paso',
    description: 'Completa tu primera actividad',
    imageUrl: '/badges/first-step.svg',
    type: BadgeType.FIRST_STEPS,
    criteria: { type: 'first_activity' },
    xpReward: 10,
    coinsReward: 5
  },
  {
    name: 'Explorador Maya',
    description: 'Completa tu primer módulo',
    imageUrl: '/badges/first-module.svg',
    type: BadgeType.MODULE_COMPLETION,
    criteria: { type: 'complete_module', value: 1 },
    xpReward: 50,
    coinsReward: 25
  },
  {
    name: 'Maestro de Números',
    description: 'Completa el módulo de Números Mayas',
    imageUrl: '/badges/numeros-master.svg',
    type: BadgeType.MODULE_COMPLETION,
    criteria: { type: 'complete_module', value: 'numeros-mayas' },
    xpReward: 30,
    coinsReward: 15
  },
  {
    name: 'Estudiante Constante',
    description: 'Completa actividades 3 días seguidos',
    imageUrl: '/badges/streak-3.svg',
    type: BadgeType.STREAK,
    criteria: { type: 'streak_days', value: 3 },
    xpReward: 20,
    coinsReward: 10
  },
  {
    name: 'Centenario',
    description: 'Alcanza 100 XP',
    imageUrl: '/badges/xp-100.svg',
    type: BadgeType.XP_MILESTONE,
    criteria: { type: 'reach_xp', value: 100 },
    xpReward: 10,
    coinsReward: 5
  },
  {
    name: 'Aprendiz Ascendido',
    description: 'Alcanza el rango de Aprendiz',
    imageUrl: '/badges/rank-aprendiz.svg',
    type: BadgeType.RANK_UP,
    criteria: { type: 'rank_achieved', value: 'aprendiz' },
    xpReward: 25,
    coinsReward: 10
  },
  // ... 4 insignias más
]

class BadgesService {
  async checkAndAwardBadges(userId: string, event: string, eventData?: any) {
    const badges = await this.badgesRepository.find()
    const userBadges = await this.userBadgesRepository.find({ where: { userId } })
    const earnedBadgeIds = userBadges.map(ub => ub.badgeId)

    const newlyEarnedBadges = []

    for (const badge of badges) {
      // Si ya la tiene, skip
      if (earnedBadgeIds.includes(badge.id)) continue

      // Verificar si cumple criterio
      const meets = await this.meetsCriteria(userId, badge.criteria, event, eventData)

      if (meets) {
        await this.awardBadge(userId, badge.id)
        newlyEarnedBadges.push(badge)
      }
    }

    return newlyEarnedBadges
  }

  private async meetsCriteria(userId: string, criteria: BadgeCriteria, event: string, eventData?: any): Promise<boolean> {
    switch (criteria.type) {
      case 'first_activity':
        return event === 'activity_completed' && eventData.isFirst

      case 'complete_module':
        if (typeof criteria.value === 'number') {
          const completedCount = await this.moduleProgressRepository.count({ where: { userId } })
          return completedCount >= criteria.value
        } else {
          return event === 'module_completed' && eventData.moduleId === criteria.value
        }

      case 'reach_xp':
        const user = await this.usersRepository.findOne({ where: { id: userId } })
        return user.totalXP >= criteria.value

      case 'streak_days':
        const streak = await this.streakService.getCurrentStreak(userId)
        return streak >= criteria.value

      case 'rank_achieved':
        const userRank = await this.usersRepository.findOne({ where: { id: userId } })
        return userRank.currentRank === criteria.value

      default:
        return false
    }
  }

  private async awardBadge(userId: string, badgeId: string) {
    const badge = await this.badgesRepository.findOne({ where: { id: badgeId } })

    await this.userBadgesRepository.save({
      userId,
      badgeId,
      earnedAt: new Date()
    })

    // Otorgar recompensas
    if (badge.xpReward > 0) {
      await this.xpService.awardXP(userId, badge.xpReward, 'badge_earned', badgeId)
    }
    if (badge.coinsReward > 0) {
      await this.coinsService.awardCoins(userId, badge.coinsReward, 'badge', badgeId)
    }

    return badge
  }

  async getBadgesGallery(userId: string) {
    const allBadges = await this.badgesRepository.find()
    const userBadges = await this.userBadgesRepository.find({
      where: { userId },
      relations: ['badge']
    })

    const earnedBadgeIds = userBadges.map(ub => ub.badgeId)

    return allBadges.map(badge => ({
      ...badge,
      earned: earnedBadgeIds.includes(badge.id),
      earnedAt: userBadges.find(ub => ub.badgeId === badge.id)?.earnedAt
    }))
  }
}

Endpoints:

GET /api/badges
- Response: { badges: [ { ...badge, earned, earnedAt } ] }

GET /api/badges/recent
- Response: { recentBadges: [...] } // Últimas 5 desbloqueadas

Frontend

// components/badges/BadgeGallery.tsx
export function BadgeGallery({ badges }) {
  return (
    <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
      {badges.map(badge => (
        <div
          key={badge.id}
          className={`relative p-4 rounded-lg border-2 transition-all ${
            badge.earned
              ? 'bg-white border-maya-gold-300 shadow-md'
              : 'bg-gray-100 border-gray-300 opacity-60'
          }`}
          title={badge.earned ? `Desbloqueada: ${new Date(badge.earnedAt).toLocaleDateString()}` : badge.description}
        >
          <img
            src={badge.imageUrl}
            alt={badge.name}
            className={`w-16 h-16 mx-auto mb-2 ${!badge.earned && 'grayscale'}`}
          />
          <p className="text-center text-sm font-medium text-gray-900">
            {badge.name}
          </p>
          {!badge.earned && (
            <div className="absolute top-2 right-2">
              <span className="text-2xl">🔒</span>
            </div>
          )}
        </div>
      ))}
    </div>
  )
}

// components/badges/BadgeUnlockModal.tsx
export function BadgeUnlockModal({ badge, onClose }) {
  useEffect(() => {
    confetti({ particleCount: 200, spread: 180 })
  }, [])

  return (
    <Modal isOpen onClose={onClose}>
      <div className="text-center py-6">
        <div className="text-6xl mb-4">🏆</div>
        <h2 className="text-2xl font-bold mb-3">¡Insignia Desbloqueada!</h2>

        <img src={badge.imageUrl} alt={badge.name} className="w-24 h-24 mx-auto mb-3" />

        <h3 className="text-xl font-bold text-maya-gold-700 mb-2">{badge.name}</h3>
        <p className="text-gray-600 mb-4">{badge.description}</p>

        {(badge.xpReward > 0 || badge.coinsReward > 0) && (
          <div className="flex justify-center gap-4 mb-4">
            {badge.xpReward > 0 && (
              <span className="text-yellow-700">+{badge.xpReward} XP</span>
            )}
            {badge.coinsReward > 0 && (
              <span className="text-gold-700">+{badge.coinsReward} 💰</span>
            )}
          </div>
        )}

        <Button onClick={onClose}>Continuar</Button>
      </div>
    </Modal>
  )
}

Dependencias

Antes: US-GAM-002, US-GAM-003


Definición de Hecho (DoD)

  • 10 insignias implementadas
  • Sistema de otorgamiento automático
  • Galería de insignias
  • Notificaciones
  • Imágenes/iconos diseñados
  • Tests

Notas

  • 10 insignias hardcoded
  • Criterios fijos
  • ⚠️ Extensión futura: EXT-026-DynamicBadges (crear insignias dinámicamente)

Estimación

Desglose (8 SP = ~3 días):

  • Backend: 1 día
  • Frontend: 1 día
  • Diseño de insignias: 0.75 días
  • Testing: 0.25 días

Creado: 2025-11-02 Responsable: Equipo Fullstack + Diseño