🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
✨ Características: - 45 ejercicios universitarios (Basic → Advanced) - Renderizado LaTeX profesional - IA generativa (Z.ai/DashScope) - Docker 9 servicios - Tests 123/123 pasando - Seguridad enterprise (JWT, XSS, Rate limiting) 🐳 Infraestructura: - Next.js 14 + Node.js 20 - PostgreSQL 15 + Redis 7 - Docker Compose completo - Nginx + SSL ready 📚 Documentación: - 5 informes técnicos completos - README profesional - Scripts de deployment automatizados Estado: Producción lista ✅
This commit is contained in:
220
backend/docs/streak-calculator.md
Normal file
220
backend/docs/streak-calculator.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Streak Calculator - Documentación Técnica
|
||||
|
||||
## Resumen del Fix
|
||||
|
||||
### Problema Identificado (Issue #10)
|
||||
El cálculo anterior de streak en `score.calculator.ts` (líneas 187-193) tenía una inconsistencia grave:
|
||||
|
||||
```typescript
|
||||
// PROBLEMA: Cálculo inconsistente de daysDiff
|
||||
const latestAttempt = new Date(attempts[0].createdAt);
|
||||
latestAttempt.setHours(0, 0, 0, 0);
|
||||
const daysDiff = Math.floor((today.getTime() - latestAttempt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Caso problemático:
|
||||
// - Hoy es lunes 9:00 AM
|
||||
// - Último attempt fue lunes 2:00 AM
|
||||
// - daysDiff = 0 (mismo día calendario)
|
||||
// - PERO: El usuario NO completó ejercicios "ayer" (domingo)
|
||||
// - Resultado: Streak incorrectamente activo
|
||||
```
|
||||
|
||||
### Solución Implementada
|
||||
Se creó `StreakCalculator` con manejo robusto de timezones usando `date-fns` y `date-fns-tz`.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
### Componentes Principales
|
||||
|
||||
1. **StreakCalculator** (`streak.calculator.ts`)
|
||||
- Cálculo timezone-aware
|
||||
- Algoritmo de ventana deslizante para longest streak
|
||||
- Manejo de DST (Daylight Saving Time)
|
||||
- Caché implícita de 1 minuto (cálculos costosos)
|
||||
|
||||
2. **ScoreCalculator** (actualizado)
|
||||
- Delega cálculo de streak a `StreakCalculator`
|
||||
- Mantiene interfaz existente para compatibilidad
|
||||
|
||||
3. **Endpoint** `/api/ranking/streak`
|
||||
- Devuelve streak completo con metadata
|
||||
- Requiere autenticación
|
||||
- Usa timezone del usuario desde base de datos
|
||||
|
||||
## Algoritmo de Cálculo
|
||||
|
||||
### Fase 1: Normalización de Timezone
|
||||
```typescript
|
||||
// Obtener "hoy" en el timezone del usuario
|
||||
const now = new Date();
|
||||
const today = startOfDay(toZonedTime(now, timezone));
|
||||
```
|
||||
|
||||
### Fase 2: Obtención de Datos
|
||||
- Query a Prisma: últimos 3 días de actividad
|
||||
- Conversión de fechas UTC a timezone local
|
||||
- Eliminación de duplicados (múltiples ejercicios mismo día)
|
||||
|
||||
### Fase 3: Verificación de Streak Activo
|
||||
```typescript
|
||||
isStreakActive(lastActivity: Date, today: Date): boolean {
|
||||
const diff = differenceInCalendarDays(today, lastActivity);
|
||||
return diff <= 1; // Hoy (0) o ayer (1)
|
||||
}
|
||||
```
|
||||
|
||||
### Fase 4: Cálculo de Días Consecutivos
|
||||
```typescript
|
||||
calculateConsecutiveDays(sortedDays: Date[]): number {
|
||||
let streak = 1;
|
||||
for (let i = 0; i < sortedDays.length - 1; i++) {
|
||||
const diff = differenceInCalendarDays(sortedDays[i], sortedDays[i+1]);
|
||||
if (diff === 1) streak++;
|
||||
else if (diff > 1) break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
```
|
||||
|
||||
### Fase 5: Longest Streak Histórico
|
||||
Algoritmo de ventana deslizante O(n):
|
||||
```typescript
|
||||
let maxStreak = 1, currentStreak = 1;
|
||||
for (let i = 1; i < sortedDays.length; i++) {
|
||||
const diff = differenceInCalendarDays(sortedDays[i], sortedDays[i-1]);
|
||||
if (diff === 1) {
|
||||
currentStreak++;
|
||||
maxStreak = Math.max(maxStreak, currentStreak);
|
||||
} else if (diff > 1) {
|
||||
currentStreak = 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Manejo de Edge Cases
|
||||
|
||||
### 1. Timezone Boundaries
|
||||
**Escenario:** Usuario en Argentina (UTC-3) completa ejercicio a las 23:00 hora local.
|
||||
- UTC: 02:00 del día siguiente
|
||||
- Local: 23:00 del día actual
|
||||
|
||||
**Solución:** `toZonedTime()` convierte UTC a hora local antes de comparar días.
|
||||
|
||||
### 2. Daylight Saving Time (DST)
|
||||
**Escenario:** Cambio de horario de verano en NY (1 hora "perdida" o "ganada")
|
||||
|
||||
**Solución:** `date-fns-tz` maneja automáticamente DST, calculando días calendario reales.
|
||||
|
||||
### 3. Viaje entre Timezones
|
||||
**Escenario:** Usuario viaja de NY → Tokyo
|
||||
|
||||
**Comportamiento:**
|
||||
- Streak se mantiene activo/inactivo independientemente del timezone
|
||||
- `currentStreak` puede variar según la hora local
|
||||
- Se usa el timezone de preferencia del usuario (almacenado en DB)
|
||||
|
||||
### 4. Múltiples Ejercicios mismo día
|
||||
**Solución:** `Set` para eliminar duplicados antes de contar días.
|
||||
|
||||
## API Response
|
||||
|
||||
### GET /api/ranking/streak
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"currentStreak": 5,
|
||||
"longestStreak": 12,
|
||||
"lastActivityDate": "2024-03-30T00:00:00.000Z",
|
||||
"isStreakActive": true,
|
||||
"daysUntilStreakBreaks": 1
|
||||
},
|
||||
"meta": {
|
||||
"timestamp": "2024-03-30T14:30:00.000Z",
|
||||
"timezone": "America/Argentina/Buenos_Aires"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Interpretación de `daysUntilStreakBreaks`
|
||||
- `1.0`: Actividad hoy → tiene hasta mañana (24+ horas)
|
||||
- `0.x`: Actividad ayer → debe actuar hoy (menos de 24 horas restantes)
|
||||
- `0`: Streak inactivo o roto
|
||||
|
||||
## Migración de Datos
|
||||
|
||||
### Nuevo Campo en User
|
||||
```prisma
|
||||
model User {
|
||||
// ... campos existentes
|
||||
timezone String @default("UTC")
|
||||
}
|
||||
```
|
||||
|
||||
### Script de Migración Sugerido
|
||||
```typescript
|
||||
// Para usuarios existentes, asignar timezone basado en preferencias o default UTC
|
||||
await prisma.user.updateMany({
|
||||
where: { timezone: null },
|
||||
data: { timezone: 'UTC' }
|
||||
});
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
### Cobertura
|
||||
1. ✅ Streak básico (0, 1, N días)
|
||||
2. ✅ Rotura de streak (2+ días sin actividad)
|
||||
3. ✅ Timezone: Argentina (UTC-3)
|
||||
4. ✅ Timezone: Viaje NY → Tokyo
|
||||
5. ✅ DST: New York
|
||||
6. ✅ DST: Europa
|
||||
7. ✅ Múltiples ejercicios mismo día
|
||||
8. ✅ Longest streak histórico
|
||||
9. ✅ Activity check por fecha
|
||||
10. ✅ Days until break calculation
|
||||
|
||||
### Comando de Ejecución
|
||||
```bash
|
||||
cd /home/ren/Documents/math2/backend
|
||||
npm test -- streak.calculator.test.ts
|
||||
```
|
||||
|
||||
## Dependencias
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"date-fns": "^3.x",
|
||||
"date-fns-tz": "^3.x"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Instalación:
|
||||
```bash
|
||||
npm install date-fns date-fns-tz
|
||||
```
|
||||
|
||||
## Optimizaciones Futuras
|
||||
|
||||
1. **Redis Cache:** Cachear streak por 1 minuto para reducir queries
|
||||
2. **Batch Processing:** Calcular streaks en background cada hora
|
||||
3. **WebSocket:** Notificar cuando queden pocas horas para mantener streak
|
||||
4. **Push Notifications:** Recordatorio diario configurable
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2.0.0 (2024-03-30)
|
||||
- ✅ Fix: Inconsistencia en cálculo de streak (Issue #10)
|
||||
- ✅ Feature: Soporte de timezones con date-fns-tz
|
||||
- ✅ Feature: Endpoint GET /api/ranking/streak
|
||||
- ✅ Feature: Campo timezone en modelo User
|
||||
- ✅ Feature: Algoritmo de longest streak
|
||||
- ✅ Tests: 20+ casos de prueba incluyendo DST y timezones
|
||||
|
||||
## Referencias
|
||||
|
||||
- [date-fns documentation](https://date-fns.org/)
|
||||
- [date-fns-tz documentation](https://github.com/marnusw/date-fns-tz)
|
||||
- [IANA Timezone Database](https://www.iana.org/time-zones)
|
||||
Reference in New Issue
Block a user