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
370 lines
9.6 KiB
TypeScript
370 lines
9.6 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|