Skip to content

Instantly share code, notes, and snippets.

@diegomarino
Last active July 5, 2025 18:38
Show Gist options
  • Select an option

  • Save diegomarino/cff1c1b662b623c82819dcb9e8022c4d to your computer and use it in GitHub Desktop.

Select an option

Save diegomarino/cff1c1b662b623c82819dcb9e8022c4d to your computer and use it in GitHub Desktop.
tampermonkey script - wetracked table with percentages
// ==UserScript==
// @name Wetracked Enhancer
// @namespace https://www.wetracked.io/
// @version 1.7
// @description Añade columnas de conversión (%), headers ordenables, ancho fijo y comportamiento dinámico a la tabla de reporting de Wetracked.io.
// @author @diegomarino
// @match https://app.wetracked.io/*
// @grant none
// @homepage https://gist.github.com/diegomarino/cff1c1b662b623c82819dcb9e8022c4d
// @supportURL https://gist.github.com/diegomarino/cff1c1b662b623c82819dcb9e8022c4d
// @updateURL https://gist.github.com/diegomarino/cff1c1b662b623c82819dcb9e8022c4d/raw/wetracked-enhancer.user.js
// @downloadURL https://gist.github.com/diegomarino/cff1c1b662b623c82819dcb9e8022c4d/raw/wetracked-enhancer.user.js
// ==/UserScript==
/**
* WETRACKED ENHANCER v1.7
* ========================
*
* AUTOR: @diegomarino
* GIST: https://gist.github.com/diegomarino/cff1c1b662b623c82819dcb9e8022c4d
*
* DESCRIPCIÓN:
* Este script mejora la tabla de reporting de Wetracked.io añadiendo columnas de
* conversión calculadas automáticamente y funcionalidad de ordenamiento.
*
* CARACTERÍSTICAS:
* ✓ Añade 3 columnas de conversión calculadas:
* - Cart → Checkout %: Porcentaje de carritos que llegan a checkout
* - Checkout → Purchase %: Porcentaje de checkouts que se convierten en compra
* - Cart → Purchase %: Porcentaje total de conversión de carrito a compra
*
* ✓ Headers clicables para ordenar por cualquier columna de conversión
* ✓ Indicadores visuales de ordenamiento (↑↓)
* ✓ Botón para copiar tabla en formato Markdown
* ✓ Ancho fijo de columnas para mejor legibilidad
* ✓ Fila de totales siempre al final
* ✓ Actualización automática cuando cambian los datos
*
* INSTALACIÓN:
* 1. Instala Tampermonkey en tu navegador
* 2. Ve al gist: https://gist.github.com/diegomarino/cff1c1b662b623c82819dcb9e8022c4d
* 3. Haz clic en "Raw" en el archivo .user.js
* 4. Tampermonkey detectará automáticamente el script y pedirá instalarlo
* 5. Confirma la instalación y activa el script
* 6. Visita app.wetracked.io y verás las nuevas columnas
*
* COMPATIBILIDAD:
* - Navegadores: Chrome, Firefox, Safari, Edge
* - Requiere: Tampermonkey o Greasemonkey
* - Versión Wetracked: Todas las versiones actuales
*
* CHANGELOG:
* v1.7 - Añadido botón para copiar tabla en formato Markdown
* v1.6 - Corregido ordenamiento de columnas de porcentaje
* v1.5 - Añadidas columnas de conversión y ordenamiento
* v1.0 - Versión inicial
*/
(function () {
'use strict';
// ============================================================================
// FUNCIONES DE EXPORTACIÓN
// ============================================================================
/**
* Extrae el texto del header principal, ignorando elementos de dropdown
* @param {HTMLElement} headerCell - Elemento del header
* @returns {string} - Texto limpio del header principal
*/
const extractHeaderText = (headerCell) => {
if (!headerCell) return '';
// Buscar el texto principal del header (generalmente el primer texto visible)
const textContent = headerCell.textContent || headerCell.innerText || '';
// Para la columna "Campaigns", solo queremos "Campaigns", no los utm_*
if (textContent.includes('Campaigns')) {
return 'Campaigns';
}
// Para otros headers, tomar la primera línea o el texto más relevante
const lines = textContent.split('\n').map(line => line.trim()).filter(line => line);
return lines[0] || '';
};
/**
* Extrae el texto limpio de una celda, manejando diferentes formatos
* @param {HTMLElement} cell - Elemento de la celda
* @returns {string} - Texto limpio de la celda
*/
const extractCellText = (cell) => {
if (!cell) return '';
// Para celdas de datos, obtener solo el texto visible principal
const textContent = cell.textContent || cell.innerText || '';
// Si hay múltiples líneas, tomar la primera (que suele ser el valor principal)
const lines = textContent.split('\n').map(line => line.trim()).filter(line => line);
return lines[0] || '';
};
/**
* Genera la tabla en formato Markdown y la copia al portapapeles
*/
const copyTableAsMarkdown = async () => {
try {
// Obtener headers originales
const headerRow = document.querySelector('div.flex.scroll-sync');
if (!headerRow) {
alert('No se pudo encontrar la tabla');
return;
}
// Extraer headers originales (excluyendo los añadidos por el script)
const originalHeaders = [];
const headerCells = headerRow.querySelectorAll('div.flex-shrink-0:not(.wetracked-custom-header)');
headerCells.forEach(cell => {
originalHeaders.push(extractHeaderText(cell)); // Usar la nueva función para headers
});
// Añadir headers de conversión
const conversionHeaders = ['Cart → Checkout %', 'Checkout → Purchase %', 'Cart → Purchase %'];
const allHeaders = [...originalHeaders, ...conversionHeaders];
// Obtener todas las filas de datos
const allRows = [...document.querySelectorAll('div.flex-grow.flex')];
const dataRows = allRows.filter(r => r.querySelectorAll('div.text-right, .tamper-column').length > 0);
// Construir markdown
let markdown = '';
// Headers
markdown += '| ' + allHeaders.join(' | ') + ' |\n';
markdown += '|' + allHeaders.map(() => '---').join('|') + '|\n';
// Filas de datos
dataRows.forEach(row => {
const rowData = [];
// Celdas originales
const originalCells = row.querySelectorAll('div.text-right:not(.tamper-column)');
originalCells.forEach(cell => {
rowData.push(extractCellText(cell)); // Esta función ya maneja múltiples líneas
});
// Celdas de conversión añadidas por el script
const conversionCells = row.querySelectorAll('.tamper-column:not(.wetracked-custom-header)');
conversionCells.forEach(cell => {
rowData.push(extractCellText(cell));
});
// Añadir fila al markdown
if (rowData.length > 0) {
markdown += '| ' + rowData.join(' | ') + ' |\n';
}
});
// Copiar al portapapeles
await navigator.clipboard.writeText(markdown);
// Mostrar confirmación
showNotification('✅ Tabla copiada en formato Markdown');
} catch (error) {
console.error('Error al copiar tabla:', error);
alert('Error al copiar la tabla. Revisa la consola para más detalles.');
}
};
/**
* Muestra una notificación temporal en la pantalla
* @param {string} message - Mensaje a mostrar
*/
const showNotification = (message) => {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #10b981;
color: white;
padding: 12px 20px;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 10000;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
`;
notification.textContent = message;
document.body.appendChild(notification);
// Animar entrada
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
// Remover después de 3 segundos
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-10px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
};
/**
* Crea y añade el botón de exportación a Markdown
*/
const createMarkdownButton = () => {
// Evitar duplicar el botón
if (document.querySelector('#wetracked-markdown-btn')) return;
const button = document.createElement('button');
button.id = 'wetracked-markdown-btn';
button.innerHTML = '📋 Copy as Markdown';
button.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: #3b82f6;
color: white;
border: none;
padding: 10px 16px;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 9999;
transition: all 0.2s ease;
`;
// Efectos hover
button.addEventListener('mouseenter', () => {
button.style.background = '#2563eb';
button.style.transform = 'translateY(-1px)';
button.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
});
button.addEventListener('mouseleave', () => {
button.style.background = '#3b82f6';
button.style.transform = 'translateY(0)';
button.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
});
// Efecto click
button.addEventListener('mousedown', () => {
button.style.transform = 'translateY(0) scale(0.98)';
});
button.addEventListener('mouseup', () => {
button.style.transform = 'translateY(-1px) scale(1)';
});
// Funcionalidad
button.addEventListener('click', copyTableAsMarkdown);
document.body.appendChild(button);
};
// ============================================================================
// CONFIGURACIÓN
// ============================================================================
const CONFIG = {
COLUMN_WIDTH: '100px',
SORT_DELAY: 150, // ms
LOAD_DELAY: 1000 // ms
};
// ============================================================================
// UTILIDADES DE PARSING
// ============================================================================
/**
* Convierte un string con formato de euro a número
* @param {string} text - Texto como "€1,234.56" o "1234,56€"
* @returns {number} - Valor numérico
*/
const parseEuro = (text) => parseFloat(text?.replace(/[,%]/g, '').replace(',', '') || 0);
/**
* Convierte un string con formato de porcentaje a número
* @param {string} text - Texto como "25.5%" o "-"
* @returns {number} - Valor numérico (25.5 para "25.5%")
*/
const parsePercentage = (text) => {
if (!text || text === '-') return 0;
const cleaned = text.replace('%', '').trim();
return parseFloat(cleaned) || 0;
};
/**
* Formatea un número como porcentaje
* @param {number} n - Número decimal (0.255 para 25.5%)
* @returns {string} - String formateado como "25.5%" o "-"
*/
const formatPct = (n) => isNaN(n) ? '-' : (n * 100).toFixed(1) + '%';
// ============================================================================
// ESTADO GLOBAL
// ============================================================================
let sortState = { col: null, asc: false };
// ============================================================================
// FUNCIONES DE CREACIÓN DE ELEMENTOS
// ============================================================================
/**
* Crea una celda de la tabla con estilos consistentes
* @param {string} text - Texto a mostrar en la celda
* @param {Object} opts - Opciones de configuración
* @param {boolean} opts.header - Si es un header (clickeable)
* @param {number} opts.index - Índice para el ordenamiento
* @returns {HTMLElement} - Elemento div configurado
*/
const createCell = (text, opts = {}) => {
const div = document.createElement("div");
div.className = `flex-shrink-0 px-2 py-2 text-right text-sm border-r ${
opts.header
? 'font-bold bg-white cursor-pointer hover:bg-gray-100 wetracked-custom-header'
: 'bg-yellow-50'
} tamper-column`;
div.style.width = CONFIG.COLUMN_WIDTH;
div.innerText = text;
if (opts.header && typeof opts.index === 'number') {
div.dataset.label = text;
div.addEventListener('click', () => sortByColumn(opts.index));
}
return div;
};
// ============================================================================
// FUNCIONES DE ORDENAMIENTO
// ============================================================================
/**
* Ordena las filas de la tabla por la columna de conversión especificada
* @param {number} conversionIndex - Índice de la columna de conversión (0, 1, 2)
*/
const sortByColumn = (conversionIndex) => {
const allRows = [...document.querySelectorAll('div.flex-grow.flex')];
const totalRow = allRows.find(r => r.innerText.includes("Results from"));
const dataRows = allRows.filter(r => r !== totalRow && r.querySelectorAll('.tamper-column').length > 0);
// Alternar dirección si es la misma columna, sino ordenar descendente
const same = sortState.col === conversionIndex;
sortState = { col: conversionIndex, asc: same ? !sortState.asc : false };
// Ordenar las filas por el valor de la columna seleccionada
dataRows.sort((a, b) => {
const tamperCells = a.querySelectorAll('.tamper-column');
const bTamperCells = b.querySelectorAll('.tamper-column');
if (tamperCells.length <= conversionIndex || bTamperCells.length <= conversionIndex) {
return 0;
}
const aVal = parsePercentage(tamperCells[conversionIndex]?.innerText);
const bVal = parsePercentage(bTamperCells[conversionIndex]?.innerText);
return sortState.asc ? aVal - bVal : bVal - aVal;
});
// Reordenar en el DOM
const parent = totalRow?.parentElement || dataRows[0]?.parentElement;
dataRows.forEach(row => parent.appendChild(row));
if (totalRow) parent.appendChild(totalRow);
updateSortIndicators();
};
/**
* Actualiza los indicadores visuales de ordenamiento en los headers
*/
const updateSortIndicators = () => {
const headers = document.querySelectorAll('.wetracked-custom-header');
headers.forEach((el, i) => {
const base = el.dataset.label;
const isActive = sortState.col === i;
const arrow = isActive ? (sortState.asc ? '↑' : '↓') : '';
el.innerText = `${base} ${arrow}`;
});
};
// ============================================================================
// FUNCIONES DE MODIFICACIÓN DE LA TABLA
// ============================================================================
/**
* Añade los headers de las columnas de conversión
*/
const addHeaders = () => {
const headerRow = document.querySelector('div.flex.scroll-sync');
if (!headerRow || headerRow.querySelector('.wetracked-custom-header')) return;
const labels = ['Cart → Checkout %', 'Checkout → Purchase %', 'Cart → Purchase %'];
labels.forEach((label, idx) => {
const el = createCell(label, { header: true, index: idx });
headerRow.appendChild(el);
});
};
/**
* Aplica ancho fijo a todas las columnas para mejor legibilidad
*/
const patchWidths = () => {
document.querySelectorAll('div.flex-shrink-0').forEach(el => {
el.style.width = CONFIG.COLUMN_WIDTH;
el.style.minWidth = CONFIG.COLUMN_WIDTH;
});
};
/**
* Procesa todas las filas de la tabla añadiendo las columnas de conversión
*/
const processTable = () => {
addHeaders();
createMarkdownButton(); // Añadir el botón cuando se procesa la tabla
const rows = [...document.querySelectorAll('div.flex-grow.flex')];
rows.forEach((row) => {
// Evitar procesar filas ya procesadas
const existing = row.querySelectorAll('.tamper-column');
if (existing.length > 0) return;
// Buscar las celdas de datos numéricos
const cells = row.querySelectorAll('div.text-right');
if (cells.length < 6) return;
// Extraer valores de las columnas originales
// Asumiendo: [0]=adds_to_cart, [1]=cart_value, [2]=checkouts, [3]=checkout_value, [4]=purchases, [5]=purchase_value
const cartVal = parseEuro(cells[1]?.innerText);
const checkoutVal = parseEuro(cells[3]?.innerText);
const purchaseVal = parseEuro(cells[5]?.innerText);
// Calcular porcentajes de conversión
const conversionRates = [
formatPct(checkoutVal / cartVal), // Cart → Checkout %
formatPct(purchaseVal / checkoutVal), // Checkout → Purchase %
formatPct(purchaseVal / cartVal) // Cart → Purchase %
];
// Estilo especial para fila de totales
const isTotal = row.innerText.includes("Results from");
if (isTotal) row.style.fontWeight = "bold";
// Añadir las nuevas columnas
conversionRates.forEach((text) => {
row.appendChild(createCell(text));
});
});
patchWidths();
};
/**
* Asegura que la fila de totales esté siempre al final
*/
const ensureTotalRowAtBottom = () => {
const rows = [...document.querySelectorAll('div.flex-grow.flex')];
const total = rows.find(r => r.innerText.includes("Results from"));
const others = rows.filter(r => r !== total);
if (total && total.parentElement) {
others.forEach(r => total.parentElement.appendChild(r));
total.parentElement.appendChild(total);
}
};
// ============================================================================
// OBSERVADOR DE CAMBIOS Y INICIALIZACIÓN
// ============================================================================
/**
* Configura el observador de mutaciones para detectar cambios en la tabla
*/
const observeDOM = () => {
const observer = new MutationObserver(() => {
setTimeout(() => {
processTable();
ensureTotalRowAtBottom();
}, CONFIG.SORT_DELAY);
});
observer.observe(document.body, { childList: true, subtree: true });
};
/**
* Inicialización del script
*/
const initialize = () => {
setTimeout(() => {
processTable();
observeDOM();
}, CONFIG.LOAD_DELAY);
};
// ============================================================================
// PUNTO DE ENTRADA
// ============================================================================
// Esperar a que la página esté completamente cargada
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
// Backup: también ejecutar en window.load por si acaso
window.addEventListener('load', initialize);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment