Last active
July 5, 2025 18:38
-
-
Save diegomarino/cff1c1b662b623c82819dcb9e8022c4d to your computer and use it in GitHub Desktop.
tampermonkey script - wetracked table with percentages
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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