Files
math2-platform/backend/src/modules/ranking/calculators/streak.calculator.ts
Renato bc43c9e772
Some checks failed
Test Suite / test-backend (push) Has been cancelled
Test Suite / test-frontend (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / coverage-check (push) Has been cancelled
🎓 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 
2026-03-31 11:27:11 -03:00

300 lines
8.4 KiB
TypeScript

/**
* 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;