✨ 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 ✅
300 lines
8.4 KiB
TypeScript
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;
|