/** * Streak Calculator * * Calcula rachas de usuario con manejo robusto de timezones usando date-fns. * Resuelve el issue de inconsistencia en el cálculo de streaks (líneas 187-193 de score.calculator.ts). */ import { differenceInCalendarDays, startOfDay, subDays, endOfDay } from 'date-fns'; import { toZonedTime, fromZonedTime } from 'date-fns-tz'; import { prisma } from '../../../shared/database/prisma.client'; import { logger } from '../../../shared/utils/logger'; // ============================================ // TYPES // ============================================ export interface StreakCalculationParams { userId: string; timezone?: string; } export interface StreakResult { currentStreak: number; longestStreak: number; lastActivityDate: Date | null; isStreakActive: boolean; daysUntilStreakBreaks: number; } // ============================================ // STREAK CALCULATOR // ============================================ export class StreakCalculator { /** * Calcula el streak actual del usuario considerando su timezone. * Resuelve el problema de inconsistencia donde el cálculo anterior no * consideraba correctamente los límites de días en diferentes timezones. */ static async calculateStreak( params: StreakCalculationParams ): Promise { const { userId, timezone = 'UTC' } = params; try { // Obtener hoy en el timezone del usuario const now = new Date(); const today = startOfDay(toZonedTime(now, timezone)); // Obtener actividad reciente (últimos 2 días para verificar streak) const recentActivity = await prisma.exerciseAttempt.findMany({ where: { userId, status: 'CORRECT', createdAt: { gte: subDays(new Date(), 3), // Últimos 3 días en UTC para cubrir timezones }, }, orderBy: { createdAt: 'desc' }, select: { createdAt: true }, }); if (recentActivity.length === 0) { const longestStreak = await this.getLongestStreak(userId); return { currentStreak: 0, longestStreak, lastActivityDate: null, isStreakActive: false, daysUntilStreakBreaks: 0, }; } // Convertir fechas al timezone del usuario const activityDates = recentActivity.map(a => startOfDay(toZonedTime(a.createdAt, timezone)) ); // Eliminar duplicados del mismo día const uniqueDays = new Set(); activityDates.forEach(date => { uniqueDays.add(date.toISOString()); }); const sortedUniqueDays = Array.from(uniqueDays) .map(d => new Date(d)) .sort((a, b) => b.getTime() - a.getTime()); // Normalizar undefined → null para consistencia de tipos const lastActivityDate: Date | null = sortedUniqueDays[0] ?? null; // Verificar si el streak está activo const isStreakActive = this.isStreakActive(lastActivityDate, today); // Calcular streak actual let currentStreak = 0; if (isStreakActive) { currentStreak = this.calculateConsecutiveDays(sortedUniqueDays); } // Calcular días restantes para mantener streak const daysUntilStreakBreaks = isStreakActive ? this.calculateDaysUntilBreak(lastActivityDate, today) : 0; const longestStreak = await this.getLongestStreak(userId); logger.info({ userId, timezone, currentStreak, isStreakActive, lastActivityDate: lastActivityDate?.toISOString() ?? null, today: today.toISOString(), }, 'Streak calculated'); return { currentStreak, longestStreak, lastActivityDate, isStreakActive, daysUntilStreakBreaks, }; } catch (error) { logger.error({ userId, timezone, error: error instanceof Error ? error.message : 'Unknown error', }, 'Error calculating streak'); throw error; } } /** * Verifica si el streak está activo. * Streak está activo si: * - Última actividad fue hoy, O * - Última actividad fue ayer (aún tiene hasta medianoche de hoy) */ private static isStreakActive( lastActivity: Date | null, today: Date ): boolean { if (!lastActivity) return false; const diff = differenceInCalendarDays(today, lastActivity); return diff <= 1; // Hoy (0) o ayer (1) } /** * Calcula días consecutivos hacia atrás desde la última actividad. * Este algoritmo verifica secuencia continua de días. */ private static calculateConsecutiveDays( sortedDays: Date[] ): number { if (sortedDays.length === 0) return 0; let streak = 1; for (let i = 0; i < sortedDays.length - 1; i++) { const current = sortedDays[i]!; const next = sortedDays[i + 1]!; const diff = differenceInCalendarDays(current, next); if (diff === 1) { // Día consecutivo streak++; } else if (diff > 1) { // Se rompió la secuencia break; } // Si diff === 0, es el mismo día (duplicado), ignorar } return streak; } /** * Obtiene el streak más largo histórico del usuario. * Usa algoritmo de ventana deslizante para encontrar máxima secuencia. */ static async getLongestStreak(userId: string): Promise { const allActivity = await prisma.exerciseAttempt.findMany({ where: { userId, status: 'CORRECT', }, orderBy: { createdAt: 'asc' }, select: { createdAt: true }, }); if (allActivity.length === 0) return 0; // Agrupar por día (usando UTC para consistencia histórica) const uniqueDays = new Set(); allActivity.forEach(a => { const date = startOfDay(a.createdAt); uniqueDays.add(date.toISOString()); }); const sortedDays = Array.from(uniqueDays) .map(d => new Date(d)) .sort((a, b) => a.getTime() - b.getTime()); if (sortedDays.length === 0) return 0; // Algoritmo de ventana deslizante para encontrar máxima secuencia let maxStreak = 1; let currentStreak = 1; for (let i = 1; i < sortedDays.length; i++) { const prevDate = sortedDays[i - 1]!; const currDate = sortedDays[i]!; const diff = differenceInCalendarDays(currDate, prevDate); if (diff === 1) { // Día consecutivo currentStreak++; maxStreak = Math.max(maxStreak, currentStreak); } else if (diff > 1) { // Se rompió la secuencia currentStreak = 1; } // Si diff === 0, múltiples ejercicios mismo día, no cuenta para streak } return maxStreak; } /** * Verifica si hay actividad en una fecha específica en un timezone. */ static async hasActivityOnDate( userId: string, date: Date, timezone: string = 'UTC' ): Promise { const startOfDate = startOfDay(date); const endOfDate = endOfDay(date); const startUTC = fromZonedTime(startOfDate, timezone); const endUTC = fromZonedTime(endOfDate, timezone); const count = await prisma.exerciseAttempt.count({ where: { userId, status: 'CORRECT', createdAt: { gte: startUTC, lte: endUTC, }, }, }); return count > 0; } /** * Calcula cuántas horas/días le quedan al usuario para mantener el streak. */ private static calculateDaysUntilBreak( lastActivity: Date | null, today: Date ): number { if (!lastActivity) return 0; const lastActivityDay = startOfDay(lastActivity); const todayDay = startOfDay(today); const diff = differenceInCalendarDays(todayDay, lastActivityDay); if (diff === 0) { // Actividad hoy: tiene hasta mañana (24+ horas) return 1; } else if (diff === 1) { // Actividad ayer: debe actuar hoy (menos de 24 horas) const hoursRemaining = 24 - new Date().getHours(); return hoursRemaining / 24; // Fracción de día } return 0; } /** * Obtiene información básica de streak para el calculador de puntos. * Versión optimizada sin cálculo de longest streak. */ static async getUserStreakInfo( userId: string, timezone?: string ): Promise<{ currentStreak: number; hasStreakBonus: boolean }> { const result = await this.calculateStreak({ userId, timezone: timezone ?? 'UTC' }); return { currentStreak: result.currentStreak, hasStreakBonus: result.currentStreak >= 3, }; } } export default StreakCalculator;