🎓 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:
299
backend/src/modules/ranking/calculators/streak.calculator.ts
Normal file
299
backend/src/modules/ranking/calculators/streak.calculator.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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<StreakResult> {
|
||||
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<string>();
|
||||
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<number> {
|
||||
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<string>();
|
||||
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<boolean> {
|
||||
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;
|
||||
Reference in New Issue
Block a user