diff --git a/admin/assets/dashboard.css b/admin/assets/dashboard.css new file mode 100644 index 0000000..bdd2aeb --- /dev/null +++ b/admin/assets/dashboard.css @@ -0,0 +1,138 @@ +/** + * ROI APU Search - Analytics Dashboard Styles + * + * @package ROI_APU_Search + * @since 1.2.0 + */ + +/* ================================================================= + WordPress Admin Fixes + ================================================================= */ + +/* Fix: WordPress limita ancho de cards */ +body .card { + max-width: none !important; +} + +/* Fix: WordPress admin puede afectar tipografia */ +.dashboard-analytics-wrap { + font-family: 'Poppins', -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* Fix: Asegurar que Bootstrap no colisione con WP admin */ +.dashboard-analytics-wrap .btn { + text-transform: none; +} + +/* Fix: Reset WP admin styles dentro del dashboard */ +.dashboard-analytics-wrap h1, +.dashboard-analytics-wrap h2, +.dashboard-analytics-wrap h3, +.dashboard-analytics-wrap h4, +.dashboard-analytics-wrap h5, +.dashboard-analytics-wrap h6 { + font-family: 'Poppins', -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* ================================================================= + Admin Notice Styles + ================================================================= */ + +.admin-notice { + border-left: 4px solid; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; +} + +/* Error - CTR 0% (critico) */ +.admin-notice.notice-error { + border-color: #ef4444; + background-color: #fef2f2; + color: #7f1d1d; +} + +/* Warning - Posts infraposicionados (oportunidad) */ +.admin-notice.notice-warning { + border-color: #f59e0b; + background-color: #fffbeb; + color: #78350f; +} + +/* Info - Sin resultados */ +.admin-notice.notice-info { + border-color: #0284c7; + background-color: #e7f3ff; + color: #0c4a6e; +} + +/* Success - Feedback export */ +.admin-notice.notice-success { + border-color: #22c55e; + background-color: #f0fdf4; + color: #166534; +} + +/* ================================================================= + Loading State + ================================================================= */ + +#loadingState { + display: none; +} + +.dashboard-analytics-wrap.is-loading #loadingState { + display: block; +} + +.dashboard-analytics-wrap.is-loading #dashboardContent { + display: none; +} + +/* ================================================================= + KPI Cards - Sin efecto hover + ================================================================= */ + +.dashboard-analytics-wrap .card { + /* Sin transiciones de movimiento */ +} + +/* ================================================================= + Tables + ================================================================= */ + +.dashboard-analytics-wrap .table { + font-size: 0.875rem; +} + +.dashboard-analytics-wrap .table th { + font-weight: 600; + white-space: nowrap; +} + +/* ================================================================= + Export Card + ================================================================= */ + +.export-card { + border: 2px dashed #dee2e6; + transition: border-color 0.2s ease; +} + +.export-card:hover { + border-color: #FF8600; +} + +/* ================================================================= + Responsive + ================================================================= */ + +@media (max-width: 782px) { + .dashboard-analytics-wrap .admin-notice { + padding: 0.75rem; + } + + .dashboard-analytics-wrap .card-body { + padding: 0.75rem; + } +} diff --git a/admin/assets/dashboard.js b/admin/assets/dashboard.js new file mode 100644 index 0000000..11efe0f --- /dev/null +++ b/admin/assets/dashboard.js @@ -0,0 +1,233 @@ +/** + * ROI APU Search - Analytics Dashboard Scripts + * + * @package ROI_APU_Search + * @since 1.2.0 + */ + +(function() { + 'use strict'; + + /** + * Initialize Bootstrap tooltips + */ + function initTooltips() { + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + if (tooltipTriggerList.length > 0 && typeof bootstrap !== 'undefined') { + [...tooltipTriggerList].map(el => new bootstrap.Tooltip(el)); + } + } + + /** + * Copy markdown to clipboard + * Implementation in FASE 9 + */ + function copyMarkdown() { + const markdown = generateMarkdown(); + + navigator.clipboard.writeText(markdown).then(function() { + showSuccessMessage('Copiado: El resumen está en tu portapapeles.'); + }).catch(function(err) { + console.error('Error al copiar:', err); + showErrorMessage('Error al copiar al portapapeles.'); + }); + } + + /** + * Generate comprehensive markdown report from dashboard data (v2) + */ + function generateMarkdown() { + const data = window.roiApuDashboardData; + if (!data) { + return '# Error: No hay datos disponibles'; + } + + let md = ''; + + // Header + md += '# Reporte Analytics - Buscador APUs\n\n'; + md += `**Período**: Últimos ${data.period} días\n`; + md += `**Generado**: ${data.generated}\n`; + md += `**Sitio**: ${data.siteUrl}\n\n`; + + // KPIs Table + md += '## Métricas Clave\n\n'; + md += '| Métrica | Valor |\n'; + md += '|---------|-------|\n'; + md += `| Búsquedas totales | ${data.kpis.totalBusquedas} |\n`; + md += `| CTR (Click-Through Rate) | ${data.kpis.ctr} |\n`; + md += `| Búsquedas sin resultados | ${data.kpis.sinResultados} |\n`; + md += `| Posición promedio clicks | ${data.kpis.posProm} |\n\n`; + + // Click Distribution + if (data.clickDistribution && data.clickDistribution.length > 0) { + md += '## Distribución de Clicks por Posición\n\n'; + md += '| Posición | Clicks | Porcentaje |\n'; + md += '|----------|--------|------------|\n'; + data.clickDistribution.forEach(function(dist) { + md += `| ${dist.posicion} | ${dist.clicks} | ${dist.porcentaje}% |\n`; + }); + md += '\n'; + } + + md += '---\n\n'; + md += '## RECOMENDACIONES ACCIONABLES\n\n'; + + // 🔴 Urgent: Zero Results + if (data.zeroResults && data.zeroResults.length > 0) { + md += '### 🔴 ACCIÓN URGENTE: Contenido a Crear\n\n'; + md += 'Los usuarios buscan esto pero NO encuentran resultados:\n\n'; + md += '| Término | Frecuencia |\n'; + md += '|---------|------------|\n'; + data.zeroResults.forEach(function(term) { + md += `| ${term.term} | ${term.frecuencia} veces |\n`; + }); + md += '\n**Acción**: Crear APUs o contenido para estos términos.\n\n'; + } + + // 🟡 CTR 0% Section + if (data.ctrZero && data.ctrZero.length > 0) { + md += '### 🟡 REVISAR: Títulos con CTR 0%\n\n'; + md += 'Estos términos tienen resultados pero nadie hace click:\n\n'; + md += '| Término | Búsquedas | Resultados |\n'; + md += '|---------|-----------|------------|\n'; + data.ctrZero.forEach(function(term) { + md += `| ${term.term} | ${term.busquedas} | ${term.resultados} |\n`; + }); + md += '\n**Acción**: Mejorar títulos y descripciones de los APUs mostrados.\n\n'; + } + + // 🎯 Quick Wins + if (data.quickWins && data.quickWins.length > 0) { + md += '### 🎯 QUICK WINS: Oportunidades Fáciles\n\n'; + md += 'Términos en posición 4-10 con buen CTR (una pequeña mejora = top 3):\n\n'; + md += '| Término | Búsquedas | CTR | Pos. Actual |\n'; + md += '|---------|-----------|-----|-------------|\n'; + data.quickWins.forEach(function(term) { + md += `| ${term.term} | ${term.busquedas} | ${term.ctr}% | ${term.posProm} |\n`; + }); + md += '\n**Acción**: Optimizar estos APUs para subir al top 3.\n\n'; + } + + // 📉 Decay Content + if (data.decayContent && data.decayContent.length > 0) { + md += '### 📉 ATENCIÓN: Contenido en Decadencia\n\n'; + md += 'Posts que perdieron >20% clicks vs período anterior:\n\n'; + md += '| Título | Cambio | Clicks (antes → ahora) |\n'; + md += '|--------|--------|------------------------|\n'; + data.decayContent.forEach(function(post) { + md += `| [${post.title}](${post.url}) | ${post.cambioPct}% | ${post.clicksAnterior} → ${post.clicksActual} |\n`; + }); + md += '\n**Acción**: Revisar si el contenido está desactualizado.\n\n'; + } + + // 🟢 Star Content + if (data.contenidoEstrella && data.contenidoEstrella.length > 0) { + md += '### 🟢 MANTENER: Tu Contenido Estrella\n\n'; + md += 'Posts con más clicks - mantén este contenido actualizado:\n\n'; + md += '| Título | Clicks | Pos. Prom. |\n'; + md += '|--------|--------|------------|\n'; + data.contenidoEstrella.forEach(function(post) { + md += `| [${post.title}](${post.url}) | ${post.clicks} | ${post.posProm} |\n`; + }); + md += '\n**Acción**: Mantener actualizado y considerar contenido relacionado.\n\n'; + } + + // Infraposicionados Section + if (data.infraposicionados && data.infraposicionados.length > 0) { + md += '### 🔼 OPORTUNIDAD: Posts Infraposicionados\n\n'; + md += 'Estos posts reciben clicks pero aparecen muy abajo:\n\n'; + md += '| Título | Clicks | Pos. Prom. |\n'; + md += '|--------|--------|------------|\n'; + data.infraposicionados.forEach(function(post) { + md += `| ${post.title} | ${post.clicks} | ${post.posProm} |\n`; + }); + md += '\n**Acción**: Mejorar scoring o relevancia de estos APUs.\n\n'; + } + + md += '---\n\n'; + + // Summary stats + md += '## Resumen de Datos\n\n'; + md += `- **Términos únicos buscados**: ${data.totals.searches}\n`; + md += `- **Posts con clicks**: ${data.totals.clicks}\n`; + md += `- **Términos sin resultados**: ${data.totals.zeroResults}\n\n`; + + // Questions for analysis + md += '## Preguntas para Análisis con IA\n\n'; + md += '1. ¿Qué contenido debería crear primero basándome en las búsquedas sin resultados?\n'; + md += '2. ¿Por qué algunos términos tienen resultados pero CTR 0%? ¿Qué puedo mejorar?\n'; + md += '3. ¿Cómo optimizo los Quick Wins para llegar al top 3?\n'; + md += '4. ¿Qué patrones veo en mi contenido estrella que debería replicar?\n'; + md += '5. ¿Hay contenido en decadencia que debería actualizar urgentemente?\n\n'; + + // Footer + md += '---\n'; + md += '*Generado por ROI APU Search Dashboard v2*\n'; + md += '*Comparte este reporte con Claude para obtener recomendaciones detalladas.*\n'; + + return md; + } + + /** + * Show success message + */ + function showSuccessMessage(message) { + const container = document.getElementById('alertContainer'); + if (!container) return; + + container.innerHTML = ` + + `; + + // Auto-hide after 3 seconds + setTimeout(function() { + container.innerHTML = ''; + }, 3000); + } + + /** + * Show error message + */ + function showErrorMessage(message) { + const container = document.getElementById('alertContainer'); + if (!container) return; + + container.innerHTML = ` + + `; + } + + /** + * Initialize dashboard + */ + function init() { + initTooltips(); + + // Bind export button + const exportBtn = document.getElementById('btn-export-md'); + if (exportBtn) { + exportBtn.addEventListener('click', copyMarkdown); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Expose functions globally for debugging + window.roiApuDashboard = { + copyMarkdown: copyMarkdown, + initTooltips: initTooltips + }; + +})(); diff --git a/admin/class-analytics-dashboard.php b/admin/class-analytics-dashboard.php new file mode 100644 index 0000000..0d1d61f --- /dev/null +++ b/admin/class-analytics-dashboard.php @@ -0,0 +1,874 @@ +get_pdo(), $db->get_prefix()); + + // Default period: 30 days + $days = 30; + + // KPIs (v1) + $kpis = $repository->getKPIs($days); + $ctr_zero = $repository->getCTRZero($days, 10); + $infraposicionados = $repository->getInfraposicionados($days, 3, 5, 10); + + // v2 Recommendations + $zero_results = $repository->getZeroResults($days, 10); + $quick_wins = $repository->getQuickWins($days, 10); + $contenido_estrella = $repository->getContenidoEstrella($days, 10); + $decay_content = $repository->getDecayContent($days, 10); + $click_distribution = $repository->getClickDistribution($days); + + // v2 Paginated tables (first page) + $top_searches = $repository->getTopSearches($days, 20); + $top_clicks = $repository->getTopClicks($days, 20); + $all_zero_results = $repository->getZeroResults($days, 20); + $total_counts = $repository->getTotalCounts($days); + + // Site URL for building permalinks + $site_url = rtrim(home_url(), '/'); + + // Format numbers for display + $total_busquedas = number_format($kpis['total_busquedas']); + $ctr = number_format($kpis['ctr'], 1) . '%'; + $sin_resultados = number_format($kpis['pct_sin_resultados'], 1) . '%'; + $pos_prom = number_format($kpis['pos_prom'], 1); + + ?> +
+
+ + +
+ + +
+
+
+

+ + +

+ + + +
+
+ +
+
+
+ + +
+
+ +
+

+
+ + +
+ + + 0; + $icon = $isPositive ? 'bi-arrow-up' : 'bi-arrow-down'; + $color = $isPositive ? '#22c55e' : '#ef4444'; + $sign = $value > 0 ? '+' : ''; + return sprintf( + ' %s%s%% vs anterior', + $icon, + $color, + $sign, + number_format($value, 1) + ); + }; + ?> +
+ +
+
+
+

+ +

+ + +
+
+
+ +
+
+
+

+ +

+ + +
+
+
+ +
+
+
+

+ +

+ + +
+
+
+ +
+
+
+

+ +

+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ +
+
+

+ 20% clicks vs período anterior. Revisa si están desactualizados.', 'roi-apu-search'); ?> +

+
+ + + + + + + + + + + + + + + + + + + + + +
+ + + % + + +
+
+
+
+ + + + +
+
+
+ + +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+
+
+ + + + +
+
+
+ + +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+
+
+
+ + + +
+
+ + +
+
+
+ + + +
+
+
+ + +
+
+
+ '#FF8600', + 'Pos 2' => '#1e3a5f', + 'Pos 3' => '#2c5282', + 'Pos 4' => '#3d6894', + 'Pos 5' => '#0E2337', + 'Pos 6+' => '#6b7280', + ]; + foreach ($click_distribution as $dist) : + $color = $position_colors[$dist['posicion']] ?? '#6b7280'; + ?> +
+
+ + clicks (%) +
+
+
+
+
+
+ +
+
+ + + +
+
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+ + % + +
+
+
+ 20) : ?> + + +
+ + +
+
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + +
+ +
// +
+ + + + + +
+
+
+ 20) : ?> + + +
+ + + +
+
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + +
+ + + +
+
+
+ 20) : ?> + + +
+ + + +
+
+ +
+

+ +

+ +
+
+ +
+ +
+
+ + + pdo = $pdo; + $this->prefix = $prefix; + } + + /** + * Get KPIs for dashboard with trends vs previous period + * + * @param int $days Number of days to query + * @return array{total_busquedas: int, pct_sin_resultados: float, clicks: int, ctr: float, pos_prom: float, trends: array} + */ + public function getKPIs(int $days = 30): array + { + // Default values + $result = [ + 'total_busquedas' => 0, + 'pct_sin_resultados' => 0.0, + 'clicks' => 0, + 'ctr' => 0.0, + 'pos_prom' => 0.0, + 'trends' => [ + 'busquedas' => 0.0, + 'ctr' => 0.0, + 'sin_resultados' => 0.0, + 'pos_prom' => 0.0, + ], + ]; + + try { + // Query 1: Basic KPIs from searches table + $sql1 = "SELECT + COUNT(*) as total_busquedas, + ROUND(SUM(zero_results)*100.0/NULLIF(COUNT(*), 0), 2) as pct_sin_resultados + FROM {$this->prefix}rcp_paginas_querys + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days DAY)"; + + $stmt1 = $this->pdo->prepare($sql1); + $stmt1->execute(['days' => $days]); + $row1 = $stmt1->fetch(); + + if ($row1) { + $result['total_busquedas'] = (int) ($row1['total_busquedas'] ?? 0); + $result['pct_sin_resultados'] = (float) ($row1['pct_sin_resultados'] ?? 0); + } + + // Query 2: Clicks, CTR (correct calculation), and average position + // CTR = búsquedas con al menos 1 click / total búsquedas + $sql2 = "SELECT + COUNT(*) as clicks, + COUNT(DISTINCT search_id) as busquedas_con_click, + ROUND(AVG(position), 1) as pos_prom + FROM {$this->prefix}rcp_paginas_querys_log + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days DAY)"; + + $stmt2 = $this->pdo->prepare($sql2); + $stmt2->execute(['days' => $days]); + $row2 = $stmt2->fetch(); + + if ($row2) { + $result['clicks'] = (int) ($row2['clicks'] ?? 0); + $result['pos_prom'] = (float) ($row2['pos_prom'] ?? 0); + + // Calculate CTR correctly: distinct searches with clicks / total searches + $busquedasConClick = (int) ($row2['busquedas_con_click'] ?? 0); + if ($result['total_busquedas'] > 0) { + $result['ctr'] = round(($busquedasConClick / $result['total_busquedas']) * 100, 1); + } + } + + // Query 3: Previous period for trends comparison + $sqlPrev1 = "SELECT + COUNT(*) as total_busquedas, + ROUND(SUM(zero_results)*100.0/NULLIF(COUNT(*), 0), 2) as pct_sin_resultados + FROM {$this->prefix}rcp_paginas_querys + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days2 DAY) + AND ts < DATE_SUB(NOW(), INTERVAL :days DAY)"; + + $stmtPrev1 = $this->pdo->prepare($sqlPrev1); + $stmtPrev1->bindValue('days', $days, PDO::PARAM_INT); + $stmtPrev1->bindValue('days2', $days * 2, PDO::PARAM_INT); + $stmtPrev1->execute(); + $prevRow1 = $stmtPrev1->fetch(); + + $sqlPrev2 = "SELECT + COUNT(*) as clicks, + COUNT(DISTINCT search_id) as busquedas_con_click, + ROUND(AVG(position), 1) as pos_prom + FROM {$this->prefix}rcp_paginas_querys_log + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days2 DAY) + AND ts < DATE_SUB(NOW(), INTERVAL :days DAY)"; + + $stmtPrev2 = $this->pdo->prepare($sqlPrev2); + $stmtPrev2->bindValue('days', $days, PDO::PARAM_INT); + $stmtPrev2->bindValue('days2', $days * 2, PDO::PARAM_INT); + $stmtPrev2->execute(); + $prevRow2 = $stmtPrev2->fetch(); + + // Calculate trends + if ($prevRow1 && $prevRow2) { + $prevBusquedas = (int) ($prevRow1['total_busquedas'] ?? 0); + $prevSinResultados = (float) ($prevRow1['pct_sin_resultados'] ?? 0); + $prevBusquedasConClick = (int) ($prevRow2['busquedas_con_click'] ?? 0); + $prevCtr = $prevBusquedas > 0 ? round(($prevBusquedasConClick / $prevBusquedas) * 100, 1) : 0; + $prevPosProm = (float) ($prevRow2['pos_prom'] ?? 0); + + // Trend = current - previous (positive = improvement for most, except pos_prom) + if ($prevBusquedas > 0) { + $result['trends']['busquedas'] = round((($result['total_busquedas'] - $prevBusquedas) / $prevBusquedas) * 100, 1); + } + if ($prevCtr > 0) { + $result['trends']['ctr'] = round($result['ctr'] - $prevCtr, 1); + } + $result['trends']['sin_resultados'] = round($result['pct_sin_resultados'] - $prevSinResultados, 1); + if ($prevPosProm > 0) { + $result['trends']['pos_prom'] = round($result['pos_prom'] - $prevPosProm, 1); + } + } + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getKPIs error: ' . $e->getMessage()); + } + + return $result; + } + + /** + * Get terms with CTR 0% (have results but no clicks) + * + * @param int $days Number of days to query + * @param int $limit Max results + * @return array + */ + public function getCTRZero(int $days = 30, int $limit = 20): array + { + try { + $sql = "SELECT s.q_term, COUNT(DISTINCT s.id) as busquedas, MAX(s.total_results) as resultados + FROM {$this->prefix}rcp_paginas_querys s + LEFT JOIN {$this->prefix}rcp_paginas_querys_log c ON s.id = c.search_id + WHERE s.total_results > 0 + AND c.id IS NULL + AND s.ts >= DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY s.q_term + HAVING busquedas >= 3 + ORDER BY busquedas DESC + LIMIT :limit"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getCTRZero error: ' . $e->getMessage()); + return []; + } + } + + /** + * Get underpositioned posts (many clicks but high position) + * + * @param int $days Number of days to query + * @param int $minClicks Minimum clicks threshold + * @param int $minPosition Minimum position threshold + * @param int $limit Max results + * @return array + */ + public function getInfraposicionados( + int $days = 30, + int $minClicks = 5, + int $minPosition = 5, + int $limit = 20 + ): array { + try { + $sql = "SELECT c.post_id, p.post_title, COUNT(*) as clicks, ROUND(AVG(c.position), 1) as pos_prom + FROM {$this->prefix}rcp_paginas_querys_log c + JOIN {$this->prefix}posts p ON c.post_id = p.ID + WHERE c.ts >= DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY c.post_id, p.post_title + HAVING clicks >= :minClicks AND pos_prom >= :minPosition + ORDER BY clicks DESC + LIMIT :limit"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('minClicks', $minClicks, PDO::PARAM_INT); + $stmt->bindValue('minPosition', $minPosition, PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getInfraposicionados error: ' . $e->getMessage()); + return []; + } + } + + // ======================================================================== + // Dashboard v2 Methods - Recomendaciones Accionables + // ======================================================================== + + /** + * Get top search terms with metrics (paginated) + * + * @param int $days Number of days to query + * @param int $limit Results per page + * @param int $offset Pagination offset + * @return array + */ + public function getTopSearches(int $days = 30, int $limit = 20, int $offset = 0): array + { + try { + $sql = "SELECT + s.q_term, + COUNT(DISTINCT s.id) as busquedas, + COUNT(DISTINCT c.id) as clicks, + ROUND(COUNT(DISTINCT c.search_id) * 100.0 / NULLIF(COUNT(DISTINCT s.id), 0), 1) as ctr, + MAX(s.total_results) as resultados + FROM {$this->prefix}rcp_paginas_querys s + LEFT JOIN {$this->prefix}rcp_paginas_querys_log c ON s.id = c.search_id + WHERE s.ts >= DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY s.q_term + ORDER BY busquedas DESC + LIMIT :limit OFFSET :offset"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->bindValue('offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getTopSearches error: ' . $e->getMessage()); + return []; + } + } + + /** + * Get top clicked posts with URL (paginated) + * + * @param int $days Number of days to query + * @param int $limit Results per page + * @param int $offset Pagination offset + * @return array + */ + public function getTopClicks(int $days = 30, int $limit = 20, int $offset = 0): array + { + try { + $sql = "SELECT + c.post_id, + p.post_title, + p.post_name, + COUNT(DISTINCT c.id) as clicks, + COUNT(DISTINCT c.search_id) as busquedas_con_click, + ROUND(AVG(c.position), 1) as pos_prom + FROM {$this->prefix}rcp_paginas_querys_log c + JOIN {$this->prefix}posts p ON c.post_id = p.ID + WHERE c.ts >= DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY c.post_id, p.post_title, p.post_name + ORDER BY clicks DESC + LIMIT :limit OFFSET :offset"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->bindValue('offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getTopClicks error: ' . $e->getMessage()); + return []; + } + } + + /** + * Get searches with zero results (paginated) + * + * @param int $days Number of days to query + * @param int $limit Results per page + * @param int $offset Pagination offset + * @return array + */ + public function getZeroResults(int $days = 30, int $limit = 20, int $offset = 0): array + { + try { + $sql = "SELECT + q_term, + COUNT(*) as frecuencia, + MAX(ts) as ultima_busqueda + FROM {$this->prefix}rcp_paginas_querys + WHERE zero_results = 1 + AND ts >= DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY q_term + ORDER BY frecuencia DESC + LIMIT :limit OFFSET :offset"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->bindValue('offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getZeroResults error: ' . $e->getMessage()); + return []; + } + } + + /** + * Get quick wins: terms in position 2-15 with some CTR + * Relaxed criteria to show more actionable data + * + * @param int $days Number of days to query + * @param int $limit Max results + * @return array + */ + public function getQuickWins(int $days = 30, int $limit = 10): array + { + try { + $sql = "SELECT + s.q_term, + COUNT(DISTINCT s.id) as busquedas, + COUNT(DISTINCT c.id) as clicks, + ROUND(COUNT(DISTINCT c.search_id) * 100.0 / NULLIF(COUNT(DISTINCT s.id), 0), 1) as ctr, + ROUND(AVG(c.position), 1) as pos_prom + FROM {$this->prefix}rcp_paginas_querys s + JOIN {$this->prefix}rcp_paginas_querys_log c ON s.id = c.search_id + WHERE s.ts >= DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY s.q_term + HAVING pos_prom BETWEEN 2 AND 15 + AND ctr >= 2.0 + AND busquedas >= 2 + ORDER BY busquedas DESC + LIMIT :limit"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getQuickWins error: ' . $e->getMessage()); + return []; + } + } + + /** + * Get star content: posts with most clicks + * + * @param int $days Number of days to query + * @param int $limit Max results + * @return array + */ + public function getContenidoEstrella(int $days = 30, int $limit = 10): array + { + try { + $sql = "SELECT + c.post_id, + p.post_title, + p.post_name, + COUNT(DISTINCT c.id) as clicks, + COUNT(DISTINCT c.search_id) as busquedas_con_click, + ROUND(AVG(c.position), 1) as pos_prom + FROM {$this->prefix}rcp_paginas_querys_log c + JOIN {$this->prefix}posts p ON c.post_id = p.ID + WHERE c.ts >= DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY c.post_id, p.post_title, p.post_name + HAVING clicks >= 3 + ORDER BY clicks DESC + LIMIT :limit"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getContenidoEstrella error: ' . $e->getMessage()); + return []; + } + } + + /** + * Get click distribution by position + * + * @param int $days Number of days to query + * @return array + */ + public function getClickDistribution(int $days = 30): array + { + try { + $sql = "SELECT + CASE + WHEN position = 1 THEN 'Pos 1' + WHEN position = 2 THEN 'Pos 2' + WHEN position = 3 THEN 'Pos 3' + WHEN position = 4 THEN 'Pos 4' + WHEN position = 5 THEN 'Pos 5' + ELSE 'Pos 6+' + END as posicion, + COUNT(*) as clicks, + ROUND(COUNT(*) * 100.0 / NULLIF(( + SELECT COUNT(*) FROM {$this->prefix}rcp_paginas_querys_log + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days2 DAY) + ), 0), 1) as porcentaje + FROM {$this->prefix}rcp_paginas_querys_log + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY posicion + ORDER BY MIN(position)"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('days2', $days, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getClickDistribution error: ' . $e->getMessage()); + return []; + } + } + + /** + * Get decaying content: posts that lost clicks vs previous period + * + * @param int $days Number of days to query + * @param int $limit Max results + * @return array + */ + public function getDecayContent(int $days = 30, int $limit = 10): array + { + try { + $sql = "SELECT + pa.post_id, + p.post_title, + p.post_name, + COALESCE(curr.clicks_actual, 0) as clicks_actual, + pa.clicks_anterior, + ROUND((COALESCE(curr.clicks_actual, 0) - pa.clicks_anterior) * 100.0 / pa.clicks_anterior, 1) as cambio_pct + FROM ( + SELECT post_id, COUNT(*) as clicks_anterior + FROM {$this->prefix}rcp_paginas_querys_log + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days2 DAY) + AND ts < DATE_SUB(NOW(), INTERVAL :days DAY) + GROUP BY post_id + ) pa + LEFT JOIN ( + SELECT post_id, COUNT(*) as clicks_actual + FROM {$this->prefix}rcp_paginas_querys_log + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days3 DAY) + GROUP BY post_id + ) curr ON pa.post_id = curr.post_id + JOIN {$this->prefix}posts p ON pa.post_id = p.ID + WHERE pa.clicks_anterior >= 3 + AND (COALESCE(curr.clicks_actual, 0) - pa.clicks_anterior) * 100.0 / pa.clicks_anterior <= -20 + ORDER BY cambio_pct ASC + LIMIT :limit"; + + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue('days', $days, PDO::PARAM_INT); + $stmt->bindValue('days2', $days * 2, PDO::PARAM_INT); + $stmt->bindValue('days3', $days, PDO::PARAM_INT); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getDecayContent error: ' . $e->getMessage()); + return []; + } + } + + /** + * Get total counts for pagination + * + * @param int $days Number of days to query + * @return array{total_searches: int, total_clicks: int, total_zero_results: int} + */ + public function getTotalCounts(int $days = 30): array + { + $result = [ + 'total_searches' => 0, + 'total_clicks' => 0, + 'total_zero_results' => 0, + ]; + + try { + // Count distinct search terms + $sql1 = "SELECT COUNT(DISTINCT q_term) as total + FROM {$this->prefix}rcp_paginas_querys + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days DAY)"; + $stmt1 = $this->pdo->prepare($sql1); + $stmt1->execute(['days' => $days]); + $result['total_searches'] = (int) $stmt1->fetchColumn(); + + // Count distinct clicked posts + $sql2 = "SELECT COUNT(DISTINCT post_id) as total + FROM {$this->prefix}rcp_paginas_querys_log + WHERE ts >= DATE_SUB(NOW(), INTERVAL :days DAY)"; + $stmt2 = $this->pdo->prepare($sql2); + $stmt2->execute(['days' => $days]); + $result['total_clicks'] = (int) $stmt2->fetchColumn(); + + // Count distinct zero result terms + $sql3 = "SELECT COUNT(DISTINCT q_term) as total + FROM {$this->prefix}rcp_paginas_querys + WHERE zero_results = 1 + AND ts >= DATE_SUB(NOW(), INTERVAL :days DAY)"; + $stmt3 = $this->pdo->prepare($sql3); + $stmt3->execute(['days' => $days]); + $result['total_zero_results'] = (int) $stmt3->fetchColumn(); + } catch (PDOException $e) { + error_log('ROI APU Metrics Repository - getTotalCounts error: ' . $e->getMessage()); + } + + return $result; + } +} diff --git a/roi-apu-search.php b/roi-apu-search.php index a34d4a7..8de8adc 100644 --- a/roi-apu-search.php +++ b/roi-apu-search.php @@ -3,7 +3,7 @@ * Plugin Name: ROI APU Search * Plugin URI: https://analisisdepreciosunitarios.com * Description: Motor de busqueda ultra-rapido para Analisis de Precios Unitarios con PDO persistente y scoring hibrido. - * Version: 1.1.0 + * Version: 1.3.0 * Author: ROI Theme * Author URI: https://analisisdepreciosunitarios.com * Text Domain: roi-apu-search @@ -22,7 +22,7 @@ if (!defined('ABSPATH')) { } // Plugin constants -define('ROI_APU_SEARCH_VERSION', '1.1.0'); +define('ROI_APU_SEARCH_VERSION', '1.3.0'); define('ROI_APU_SEARCH_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('ROI_APU_SEARCH_PLUGIN_URL', plugin_dir_url(__FILE__)); define('ROI_APU_SEARCH_PLUGIN_BASENAME', plugin_basename(__FILE__)); @@ -64,6 +64,12 @@ final class ROI_APU_Search_Plugin require_once ROI_APU_SEARCH_PLUGIN_DIR . 'includes/class-search-engine.php'; require_once ROI_APU_SEARCH_PLUGIN_DIR . 'includes/class-shortcode.php'; require_once ROI_APU_SEARCH_PLUGIN_DIR . 'includes/class-analytics.php'; + + // Admin dashboard + if (is_admin()) { + require_once ROI_APU_SEARCH_PLUGIN_DIR . 'admin/class-metrics-repository.php'; + require_once ROI_APU_SEARCH_PLUGIN_DIR . 'admin/class-analytics-dashboard.php'; + } } /** @@ -80,6 +86,11 @@ final class ROI_APU_Search_Plugin // AJAX handlers (for non-SHORTINIT fallback) add_action('wp_ajax_roi_apu_search', [$this, 'handle_ajax_search']); add_action('wp_ajax_nopriv_roi_apu_search', [$this, 'handle_ajax_search']); + + // Initialize admin dashboard + if (is_admin()) { + ROI_APU_Analytics_Dashboard::get_instance()->init(); + } } /** diff --git a/sql/create-indices.sql b/sql/create-indices.sql new file mode 100644 index 0000000..b28b9f9 --- /dev/null +++ b/sql/create-indices.sql @@ -0,0 +1,11 @@ +-- ROI APU Search - Índices adicionales para Dashboard v2 +-- Ejecutar manualmente si se detectan problemas de rendimiento +-- Fecha: 2025-12-03 + +-- Índice para queries de log por timestamp +CREATE INDEX IF NOT EXISTS idx_log_ts +ON wp_rcp_paginas_querys_log(ts); + +-- Índice compuesto para filtrar búsquedas sin resultados +CREATE INDEX IF NOT EXISTS idx_zero_results_ts +ON wp_rcp_paginas_querys(zero_results, ts);