# 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)