✅ FASE 5 COMPLETADA: Analytics y Administración
Implementados 5 módulos de analytics con agent swarm: 1. DASHBOARD ADMINISTRATIVO - Resumen ejecutivo (reservas, ingresos, usuarios) - Vista del día con alertas - Calendario semanal de ocupación 2. MÉTRICAS DE OCUPACIÓN - Ocupación por fecha, cancha, franja horaria - Horas pico (top 5 demandados) - Comparativa entre períodos - Tendencias de uso 3. MÉTRICAS FINANCIERAS - Ingresos por período, cancha, tipo - Métodos de pago más usados - Estadísticas de reembolsos - Tendencias de crecimiento - Top días de ingresos 4. MÉTRICAS DE USUARIOS - Stats generales y actividad - Top jugadores (por partidos/victorias/puntos) - Detección de churn (riesgo de abandono) - Tasa de retención - Crecimiento mensual 5. EXPORTACIÓN DE DATOS - Exportar a CSV (separado por ;) - Exportar a JSON - Exportar a Excel (múltiples hojas) - Reportes completos descargables Endpoints nuevos (solo admin): - /analytics/dashboard/* - /analytics/occupancy/* - /analytics/revenue/* - /analytics/reports/* - /analytics/users/* - /analytics/exports/* Dependencias: - xlsx - Generación de archivos Excel Utilidades: - Cálculo de crecimiento porcentual - Formateo de moneda - Agrupación por fechas - Relleno de fechas faltantes
This commit is contained in:
369
backend/src/utils/analytics.ts
Normal file
369
backend/src/utils/analytics.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Utilidades para análisis y métricas
|
||||
*/
|
||||
|
||||
// Tipos
|
||||
export type GroupByPeriod = 'day' | 'week' | 'month';
|
||||
|
||||
/**
|
||||
* Formatear monto a moneda
|
||||
* @param amount Monto en centavos
|
||||
* @param currency Código de moneda (default: ARS)
|
||||
* @returns String formateado
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string = 'ARS'): string {
|
||||
const amountInUnits = amount / 100;
|
||||
|
||||
const formatter = new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
return formatter.format(amountInUnits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear monto simple (sin símbolo de moneda)
|
||||
* @param amount Monto en centavos
|
||||
* @returns String formateado
|
||||
*/
|
||||
export function formatAmount(amount: number): string {
|
||||
const amountInUnits = amount / 100;
|
||||
return amountInUnits.toLocaleString('es-AR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular crecimiento porcentual
|
||||
* @param current Valor actual
|
||||
* @param previous Valor anterior (null si no hay datos previos)
|
||||
* @returns Porcentaje de crecimiento o null
|
||||
*/
|
||||
export function calculateGrowth(current: number, previous: number | null): number | null {
|
||||
if (previous === null || previous === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (previous === 0) {
|
||||
return current > 0 ? 100 : 0;
|
||||
}
|
||||
|
||||
const growth = ((current - previous) / previous) * 100;
|
||||
return Math.round(growth * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrupar datos por fecha
|
||||
* @param data Array de datos con campo de fecha
|
||||
* @param dateField Nombre del campo de fecha
|
||||
* @param groupBy Período de agrupación (day, week, month)
|
||||
* @returns Objeto con datos agrupados
|
||||
*/
|
||||
export function groupByDate<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
dateField: string,
|
||||
groupBy: GroupByPeriod
|
||||
): Record<string, T[]> {
|
||||
const grouped: Record<string, T[]> = {};
|
||||
|
||||
for (const item of data) {
|
||||
const date = new Date(item[dateField]);
|
||||
let key: string;
|
||||
|
||||
switch (groupBy) {
|
||||
case 'day':
|
||||
key = date.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
break;
|
||||
case 'week':
|
||||
key = getWeekKey(date); // YYYY-WXX
|
||||
break;
|
||||
case 'month':
|
||||
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM
|
||||
break;
|
||||
default:
|
||||
key = date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push(item);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rellenar fechas faltantes con valores en cero
|
||||
* @param data Array de datos con campo date
|
||||
* @param startDate Fecha de inicio
|
||||
* @param endDate Fecha de fin
|
||||
* @param groupBy Período de agrupación
|
||||
* @returns Array con todas las fechas incluidas las faltantes
|
||||
*/
|
||||
export function fillMissingDates<T extends { date: string }>(
|
||||
data: T[],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
groupBy: GroupByPeriod
|
||||
): T[] {
|
||||
const result: T[] = [];
|
||||
const dataMap = new Map(data.map(d => [d.date, d]));
|
||||
|
||||
const current = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
while (current <= end) {
|
||||
let key: string;
|
||||
|
||||
switch (groupBy) {
|
||||
case 'day':
|
||||
key = current.toISOString().split('T')[0];
|
||||
current.setDate(current.getDate() + 1);
|
||||
break;
|
||||
case 'week':
|
||||
key = getWeekKey(current);
|
||||
current.setDate(current.getDate() + 7);
|
||||
break;
|
||||
case 'month':
|
||||
key = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}`;
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
break;
|
||||
default:
|
||||
key = current.toISOString().split('T')[0];
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
if (dataMap.has(key)) {
|
||||
result.push(dataMap.get(key)!);
|
||||
} else {
|
||||
// Crear objeto vacío con valores por defecto
|
||||
const emptyItem: any = { date: key };
|
||||
|
||||
// Detectar campos numéricos del primer elemento y ponerlos en 0
|
||||
if (data.length > 0) {
|
||||
const firstItem = data[0];
|
||||
for (const [field, value] of Object.entries(firstItem)) {
|
||||
if (field !== 'date' && typeof value === 'number') {
|
||||
emptyItem[field] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(emptyItem);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular promedio de un array de números
|
||||
* @param values Array de números
|
||||
* @returns Promedio
|
||||
*/
|
||||
export function calculateAverage(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular mediana de un array de números
|
||||
* @param values Array de números
|
||||
* @returns Mediana
|
||||
*/
|
||||
export function calculateMedian(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
return sorted[middle];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular percentil
|
||||
* @param values Array de números
|
||||
* @param percentile Percentil a calcular (0-100)
|
||||
* @returns Valor del percentil
|
||||
*/
|
||||
export function calculatePercentile(values: number[], percentile: number): number {
|
||||
if (values.length === 0) return 0;
|
||||
if (percentile < 0 || percentile > 100) return 0;
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = (percentile / 100) * (sorted.length - 1);
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
const weight = index - lower;
|
||||
|
||||
if (upper >= sorted.length) return sorted[lower];
|
||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular desviación estándar
|
||||
* @param values Array de números
|
||||
* @returns Desviación estándar
|
||||
*/
|
||||
export function calculateStandardDeviation(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
|
||||
const avg = calculateAverage(values);
|
||||
const squareDiffs = values.map(value => Math.pow(value - avg, 2));
|
||||
const avgSquareDiff = calculateAverage(squareDiffs);
|
||||
|
||||
return Math.sqrt(avgSquareDiff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrupar datos por campo
|
||||
* @param data Array de datos
|
||||
* @param field Campo para agrupar
|
||||
* @returns Objeto con datos agrupados
|
||||
*/
|
||||
export function groupByField<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
field: string
|
||||
): Record<string, T[]> {
|
||||
return data.reduce((acc, item) => {
|
||||
const key = item[field]?.toString() || 'unknown';
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, T[]>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular tasa de conversión
|
||||
* @param completed Número de completados
|
||||
* @param total Número total
|
||||
* @returns Porcentaje de conversión
|
||||
*/
|
||||
export function calculateConversionRate(completed: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return Math.round((completed / total) * 100 * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear número con separadores de miles
|
||||
* @param value Número a formatear
|
||||
* @returns String formateado
|
||||
*/
|
||||
export function formatNumber(value: number): string {
|
||||
return value.toLocaleString('es-AR');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear porcentaje
|
||||
* @param value Valor decimal (0-1) o porcentaje (0-100)
|
||||
* @param isDecimal Si el valor está en formato decimal
|
||||
* @returns String formateado con símbolo %
|
||||
*/
|
||||
export function formatPercentage(value: number, isDecimal: boolean = false): string {
|
||||
const percentage = isDecimal ? value * 100 : value;
|
||||
return `${Math.round(percentage * 100) / 100}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener clave de semana para una fecha
|
||||
* @param date Fecha
|
||||
* @returns String en formato YYYY-WXX
|
||||
*/
|
||||
function getWeekKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
|
||||
// Calcular número de semana (aproximado)
|
||||
const startOfYear = new Date(year, 0, 1);
|
||||
const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000));
|
||||
const weekNumber = Math.ceil((days + startOfYear.getDay() + 1) / 7);
|
||||
|
||||
return `${year}-W${String(weekNumber).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparar dos períodos de fechas
|
||||
* @param currentStart Inicio período actual
|
||||
* @param currentEnd Fin período actual
|
||||
* @param previousStart Inicio período anterior
|
||||
* @param previousEnd Fin período anterior
|
||||
* @returns Objeto con comparación
|
||||
*/
|
||||
export function comparePeriods(
|
||||
currentStart: Date,
|
||||
currentEnd: Date,
|
||||
previousStart: Date,
|
||||
previousEnd: Date
|
||||
): {
|
||||
currentDuration: number;
|
||||
previousDuration: number;
|
||||
durationRatio: number;
|
||||
} {
|
||||
const currentDuration = currentEnd.getTime() - currentStart.getTime();
|
||||
const previousDuration = previousEnd.getTime() - previousStart.getTime();
|
||||
|
||||
return {
|
||||
currentDuration,
|
||||
previousDuration,
|
||||
durationRatio: previousDuration > 0 ? currentDuration / previousDuration : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar rango de fechas
|
||||
* @param startDate Fecha de inicio
|
||||
* @param endDate Fecha de fin
|
||||
* @returns Array de fechas
|
||||
*/
|
||||
export function generateDateRange(startDate: Date, endDate: Date): Date[] {
|
||||
const dates: Date[] = [];
|
||||
const current = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
while (current <= end) {
|
||||
dates.push(new Date(current));
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redondear a número específico de decimales
|
||||
* @param value Valor a redondear
|
||||
* @param decimals Número de decimales
|
||||
* @returns Valor redondeado
|
||||
*/
|
||||
export function roundToDecimals(value: number, decimals: number = 2): number {
|
||||
const multiplier = Math.pow(10, decimals);
|
||||
return Math.round(value * multiplier) / multiplier;
|
||||
}
|
||||
|
||||
export default {
|
||||
formatCurrency,
|
||||
formatAmount,
|
||||
calculateGrowth,
|
||||
groupByDate,
|
||||
fillMissingDates,
|
||||
calculateAverage,
|
||||
calculateMedian,
|
||||
calculatePercentile,
|
||||
calculateStandardDeviation,
|
||||
groupByField,
|
||||
calculateConversionRate,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
comparePeriods,
|
||||
generateDateRange,
|
||||
roundToDecimals,
|
||||
};
|
||||
Reference in New Issue
Block a user