# US-GAM-007: Leaderboard simple
**Épica:** EAI-003 - Gamificación Básica
**Sprint:** Mes 1, Semana 4
**Story Points:** 8 SP
**Presupuesto:** $2,900 MXN
**Prioridad:** Media (Alcance Inicial)
**Estado:** ✅ Completada (Mes 1)
---
## Descripción
Como **estudiante**, quiero **ver una tabla de clasificación** para **compararme con otros estudiantes y sentirme motivado a mejorar**.
**Contexto del Alcance Inicial:**
Leaderboard básico global por XP. Top 10 estudiantes. SIN filtros avanzados (por escuela, amigos, tiempo). SIN comparativas personalizadas.
---
## Criterios de Aceptación
- [ ] **CA-01:** Muestra top 10 estudiantes por XP total
- [ ] **CA-02:** Se actualiza en tiempo real (o cada 5 minutos)
- [ ] **CA-03:** Muestra: posición, nombre, XP, rango
- [ ] **CA-04:** Resalta posición del usuario actual
- [ ] **CA-05:** Si el usuario no está en top 10, muestra su posición debajo
- [ ] **CA-06:** Accesible desde navbar
- [ ] **CA-07:** Responsive design
---
## Especificaciones Técnicas
### Backend
```typescript
class LeaderboardService {
async getGlobalLeaderboard(limit = 10) {
const topUsers = await this.usersRepository.find({
select: ['id', 'firstName', 'lastName', 'totalXP', 'currentRank', 'photoUrl'],
order: { totalXP: 'DESC' },
take: limit
})
return topUsers.map((user, index) => ({
position: index + 1,
userId: user.id,
name: `${user.firstName} ${user.lastName}`,
xp: user.totalXP,
rank: user.currentRank,
photoUrl: user.photoUrl
}))
}
async getUserPosition(userId: string) {
// Query para obtener posición exacta
const result = await this.usersRepository
.createQueryBuilder('user')
.select('COUNT(*) + 1', 'position')
.where('user.totalXP > (SELECT totalXP FROM users WHERE id = :userId)', { userId })
.getRawOne()
const user = await this.usersRepository.findOne({
where: { id: userId },
select: ['id', 'firstName', 'lastName', 'totalXP', 'currentRank', 'photoUrl']
})
return {
position: parseInt(result.position),
userId: user.id,
name: `${user.firstName} ${user.lastName}`,
xp: user.totalXP,
rank: user.currentRank,
photoUrl: user.photoUrl
}
}
async getLeaderboardWithUser(userId: string, limit = 10) {
const topUsers = await this.getGlobalLeaderboard(limit)
const userInTop = topUsers.find(u => u.userId === userId)
if (userInTop) {
return {
topUsers,
currentUser: userInTop
}
}
// Si no está en top, obtener su posición
const userPosition = await this.getUserPosition(userId)
return {
topUsers,
currentUser: userPosition
}
}
}
```
**Endpoints:**
```
GET /api/leaderboard
- Response: {
topUsers: [
{ position, userId, name, xp, rank, photoUrl }
],
currentUser: { position, userId, name, xp, rank }
}
GET /api/leaderboard/position/:userId
- Response: { position, xp, totalUsers }
```
**Caching (opcional para performance):**
```typescript
// Cachear leaderboard por 5 minutos
@CacheKey('leaderboard:global')
@CacheTTL(300)
async getGlobalLeaderboard() { ... }
```
### Frontend
```typescript
// pages/LeaderboardPage.tsx
export function LeaderboardPage() {
const [leaderboard, setLeaderboard] = useState(null)
const userId = useAuthStore(state => state.user?.id)
useEffect(() => {
loadLeaderboard()
// Refrescar cada 30 segundos
const interval = setInterval(loadLeaderboard, 30000)
return () => clearInterval(interval)
}, [])
const loadLeaderboard = async () => {
const data = await leaderboardService.getLeaderboard()
setLeaderboard(data)
}
if (!leaderboard) return
Actualizado hace {getTimeSinceUpdate()}
{name} {isCurrentUser && '(Tú)'}
{rank}
{xp.toLocaleString()}
XP