feat(analytics): Dashboard v2 con recomendaciones accionables y UX mejorada

- Agregar KPIs con tendencias vs período anterior (↑↓% comparativo)
- Implementar secciones de recomendaciones: Contenido a Crear, CTR 0%,
  Quick Wins, Contenido Estrella, Contenido en Decadencia
- Convertir listados a tablas con columnas separadas para mejor legibilidad
- Agregar botones Editar + Ver en todas las tablas de posts
- Ocultar secciones vacías dinámicamente (Búsquedas Sin Resultados)
- Relajar criterios Quick Wins: pos 2-15, CTR ≥2%, búsquedas ≥2
- Incluir distribución de clicks por posición con barras de progreso
- Agregar exportación a Markdown para análisis con IA

Archivos nuevos:
- admin/class-analytics-dashboard.php (UI del dashboard)
- admin/class-metrics-repository.php (queries de métricas)
- admin/assets/dashboard.css (estilos Bootstrap 5)
- admin/assets/dashboard.js (interactividad y export)
- sql/create-indices.sql (índices para optimización)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-03 20:15:56 -06:00
parent 255d720db6
commit 41fb658ca7
6 changed files with 1821 additions and 2 deletions

138
admin/assets/dashboard.css Normal file
View File

@@ -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;
}
}

233
admin/assets/dashboard.js Normal file
View File

@@ -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 = `
<div class="admin-notice notice-success mb-3" role="alert">
<i class="bi bi-check-circle me-2"></i>
<strong>${message}</strong>
</div>
`;
// 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 = `
<div class="admin-notice notice-error mb-3" role="alert">
<i class="bi bi-exclamation-circle me-2"></i>
<strong>${message}</strong>
</div>
`;
}
/**
* 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
};
})();

View File

@@ -0,0 +1,874 @@
<?php
/**
* Analytics Dashboard for ROI APU Search
*
* @package ROI_APU_Search
* @since 1.2.0
*/
declare(strict_types=1);
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
/**
* Main dashboard class for search analytics
*/
final class ROI_APU_Analytics_Dashboard
{
/**
* Singleton instance
*/
private static ?self $instance = null;
/**
* Menu slug
*/
private const MENU_SLUG = 'roi-apu-analytics';
/**
* Get singleton instance
*/
public static function get_instance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct()
{
// Private constructor for singleton
}
/**
* Initialize the dashboard
*/
public function init(): void
{
add_action('admin_menu', [$this, 'register_menu']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
}
/**
* Enqueue dashboard assets (CSS/JS)
*
* @param string $hook Current admin page hook
*/
public function enqueue_assets(string $hook): void
{
// Only load on our dashboard page
if (strpos($hook, self::MENU_SLUG) === false) {
return;
}
// Google Fonts - Poppins
wp_enqueue_style(
'poppins-font',
'https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap',
[],
null
);
// Bootstrap 5 CSS
wp_enqueue_style(
'bootstrap',
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css',
[],
'5.3.2'
);
// Bootstrap Icons
wp_enqueue_style(
'bootstrap-icons',
'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css',
[],
'1.11.3'
);
// Dashboard CSS
wp_enqueue_style(
'roi-apu-dashboard',
ROI_APU_SEARCH_PLUGIN_URL . 'admin/assets/dashboard.css',
['bootstrap', 'bootstrap-icons'],
ROI_APU_SEARCH_VERSION
);
// Bootstrap 5 JS Bundle (includes Popper)
wp_enqueue_script(
'bootstrap',
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js',
[],
'5.3.2',
true
);
// Dashboard JS
wp_enqueue_script(
'roi-apu-dashboard',
ROI_APU_SEARCH_PLUGIN_URL . 'admin/assets/dashboard.js',
['bootstrap'],
ROI_APU_SEARCH_VERSION,
true
);
}
/**
* Register admin menu page
*/
public function register_menu(): void
{
add_menu_page(
__('APU Analytics', 'roi-apu-search'), // Page title
__('APU Analytics', 'roi-apu-search'), // Menu title
'manage_options', // Capability
self::MENU_SLUG, // Menu slug
[$this, 'render_page'], // Callback
'dashicons-chart-bar', // Icon
30 // Position
);
}
/**
* Render the dashboard page
*/
public function render_page(): void
{
// Check user capabilities
if (!current_user_can('manage_options')) {
return;
}
// Get metrics from repository
$db = ROI_APU_Search_DB::get_instance();
$repository = new ROI_APU_Metrics_Repository($db->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);
?>
<div class="dashboard-analytics-wrap">
<div class="container-fluid py-4" style="max-width: 1400px;">
<!-- Alert Container for JS messages -->
<div id="alertContainer"></div>
<!-- Header with navy gradient -->
<div class="rounded p-4 mb-4 shadow text-white"
style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%);
border-left: 4px solid #FF8600;">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<h4 class="mb-1 d-flex align-items-center gap-2">
<i class="bi bi-bar-chart-line" style="color: #FF8600;"></i>
<?php esc_html_e('Analytics del Buscador', 'roi-apu-search'); ?>
</h4>
<small class="text-white-50">
<?php esc_html_e('Métricas y oportunidades de mejora', 'roi-apu-search'); ?>
</small>
</div>
<div class="d-flex gap-2">
<select class="form-select form-select-sm"
id="period-selector"
style="width: auto;">
<option value="30"><?php esc_html_e('Últimos 30 días', 'roi-apu-search'); ?></option>
<option value="7"><?php esc_html_e('Últimos 7 días', 'roi-apu-search'); ?></option>
<option value="90"><?php esc_html_e('Últimos 90 días', 'roi-apu-search'); ?></option>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div id="loadingState" class="text-center py-5">
<div class="spinner-border" style="color: #FF8600;" role="status">
<span class="visually-hidden"><?php esc_html_e('Cargando...', 'roi-apu-search'); ?></span>
</div>
<p class="mt-2 text-muted"><?php esc_html_e('Cargando métricas...', 'roi-apu-search'); ?></p>
</div>
<!-- Dashboard Content -->
<div id="dashboardContent">
<!-- KPIs Cards with Trends -->
<?php
$trends = $kpis['trends'];
// Helper function for trend display
$getTrendHtml = function($value, $inverse = false) {
if ($value == 0) return '';
$isPositive = $inverse ? $value < 0 : $value > 0;
$icon = $isPositive ? 'bi-arrow-up' : 'bi-arrow-down';
$color = $isPositive ? '#22c55e' : '#ef4444';
$sign = $value > 0 ? '+' : '';
return sprintf(
'<small class="d-block mt-1" style="font-size: 0.7rem;"><i class="bi %s" style="color: %s;"></i> %s%s%% vs anterior</small>',
$icon,
$color,
$sign,
number_format($value, 1)
);
};
?>
<div class="row g-3 mb-4">
<!-- KPI 1: Búsquedas -->
<div class="col-6 col-md-3">
<div class="card shadow-sm text-white" style="background-color: #FF8600;">
<div class="card-body text-center py-3">
<h2 class="mb-0 fw-bold"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="<?php esc_attr_e('Total de búsquedas realizadas en el período', 'roi-apu-search'); ?>">
<?php echo esc_html($total_busquedas); ?>
</h2>
<small><i class="bi bi-search me-1"></i><?php esc_html_e('Búsquedas', 'roi-apu-search'); ?></small>
<?php echo $getTrendHtml($trends['busquedas']); ?>
</div>
</div>
</div>
<!-- KPI 2: CTR -->
<div class="col-6 col-md-3">
<div class="card shadow-sm text-white" style="background-color: #1e3a5f;">
<div class="card-body text-center py-3">
<h2 class="mb-0 fw-bold"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="<?php esc_attr_e('Click-Through Rate: % de búsquedas que resultan en un click', 'roi-apu-search'); ?>">
<?php echo esc_html($ctr); ?>
</h2>
<small><i class="bi bi-hand-index me-1"></i><?php esc_html_e('CTR', 'roi-apu-search'); ?></small>
<?php echo $getTrendHtml($trends['ctr']); ?>
</div>
</div>
</div>
<!-- KPI 3: Sin Resultados -->
<div class="col-6 col-md-3">
<div class="card shadow-sm" style="background-color: #f8f9fa; border-left: 3px solid #dc3545;">
<div class="card-body text-center py-3">
<h2 class="mb-0 fw-bold text-danger"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="<?php esc_attr_e('% de búsquedas que no encontraron resultados', 'roi-apu-search'); ?>">
<?php echo esc_html($sin_resultados); ?>
</h2>
<small class="text-muted"><i class="bi bi-x-circle me-1"></i><?php esc_html_e('Sin Resultados', 'roi-apu-search'); ?></small>
<?php echo $getTrendHtml($trends['sin_resultados'], true); ?>
</div>
</div>
</div>
<!-- KPI 4: Posición Promedio -->
<div class="col-6 col-md-3">
<div class="card shadow-sm" style="background-color: #f8f9fa; border-left: 3px solid #1e3a5f;">
<div class="card-body text-center py-3">
<h2 class="mb-0 fw-bold" style="color: #1e3a5f;"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="<?php esc_attr_e('Posición promedio de los resultados clickeados', 'roi-apu-search'); ?>">
<?php echo esc_html($pos_prom); ?>
</h2>
<small class="text-muted"><i class="bi bi-sort-numeric-down me-1"></i><?php esc_html_e('Pos. Prom.', 'roi-apu-search'); ?></small>
<?php echo $getTrendHtml($trends['pos_prom'], true); ?>
</div>
</div>
</div>
</div>
<!-- ═══════════════ RECOMENDACIONES ACCIONABLES ═══════════════ -->
<!-- 🔴 ACCIÓN URGENTE: Contenido a Crear -->
<?php if (!empty($zero_results)) : ?>
<div class="recommendation-card mb-4" role="alert"
style="border-left: 4px solid #ef4444; background-color: #fef2f2; border-radius: 0.5rem; padding: 1rem;">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 d-flex align-items-center gap-2" style="color: #7f1d1d;">
<i class="bi bi-exclamation-triangle-fill"></i>
<?php esc_html_e('ACCIÓN URGENTE: Contenido a Crear', 'roi-apu-search'); ?>
</h6>
<span class="badge" style="background-color: #ef4444;"><?php echo count($zero_results); ?></span>
</div>
<p class="small mb-2" style="color: #991b1b;">
<?php esc_html_e('Los usuarios buscan esto pero NO encuentran nada. ¡Crea este contenido!', 'roi-apu-search'); ?>
</p>
<div class="small">
<?php foreach ($zero_results as $index => $term) : ?>
<div class="d-flex justify-content-between py-1 <?php echo $index < count($zero_results) - 1 ? 'border-bottom' : ''; ?>" style="border-color: #fecaca !important;">
<span style="color: #7f1d1d;">
<strong><?php echo esc_html($term['q_term']); ?></strong>
</span>
<span style="color: #991b1b;">
<?php echo esc_html(number_format((int)$term['frecuencia'])); ?> <?php esc_html_e('veces buscado', 'roi-apu-search'); ?>
</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- 🟡 REVISAR: Títulos con CTR 0% -->
<?php if (!empty($ctr_zero)) : ?>
<div class="recommendation-card mb-4" role="alert"
style="border-left: 4px solid #f59e0b; background-color: #fffbeb; border-radius: 0.5rem; padding: 1rem;">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 d-flex align-items-center gap-2" style="color: #78350f;">
<i class="bi bi-pencil-square"></i>
<?php esc_html_e('REVISAR: Títulos con CTR 0%', 'roi-apu-search'); ?>
</h6>
<span class="badge" style="background-color: #f59e0b;"><?php echo count($ctr_zero); ?></span>
</div>
<p class="small mb-2" style="color: #92400e;">
<?php esc_html_e('Estos términos tienen resultados pero nadie hace click. Mejora títulos y descripciones.', 'roi-apu-search'); ?>
</p>
<div class="small">
<?php foreach ($ctr_zero as $index => $term) : ?>
<div class="d-flex justify-content-between py-1 <?php echo $index < count($ctr_zero) - 1 ? 'border-bottom' : ''; ?>" style="border-color: #fde68a !important;">
<span style="color: #78350f;">
<strong><?php echo esc_html($term['q_term']); ?></strong>
<small>(<?php echo esc_html(number_format((int)$term['resultados'])); ?> <?php esc_html_e('resultados', 'roi-apu-search'); ?>)</small>
</span>
<span style="color: #92400e;">
<?php echo esc_html(number_format((int)$term['busquedas'])); ?> <?php esc_html_e('búsquedas', 'roi-apu-search'); ?> → 0 clicks
</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- 🎯 QUICK WINS: Oportunidades Fáciles -->
<?php if (!empty($quick_wins)) : ?>
<div class="recommendation-card mb-4" role="alert"
style="border-left: 4px solid #0284c7; background-color: #e7f3ff; border-radius: 0.5rem; padding: 1rem;">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 d-flex align-items-center gap-2" style="color: #0c4a6e;">
<i class="bi bi-bullseye"></i>
<?php esc_html_e('QUICK WINS: Oportunidades Fáciles', 'roi-apu-search'); ?>
</h6>
<span class="badge" style="background-color: #0284c7;"><?php echo count($quick_wins); ?></span>
</div>
<p class="small mb-2" style="color: #075985;">
<?php esc_html_e('Términos con clicks que no están en top 1. Una pequeña mejora = más visibilidad.', 'roi-apu-search'); ?>
</p>
<div class="small">
<?php foreach ($quick_wins as $index => $term) : ?>
<div class="d-flex justify-content-between py-1 <?php echo $index < count($quick_wins) - 1 ? 'border-bottom' : ''; ?>" style="border-color: #bae6fd !important;">
<span style="color: #0c4a6e;">
<strong><?php echo esc_html($term['q_term']); ?></strong>
</span>
<span style="color: #075985;">
<?php esc_html_e('Pos.', 'roi-apu-search'); ?> <?php echo esc_html($term['pos_prom']); ?>,
CTR <?php echo esc_html($term['ctr']); ?>%
</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- 📉 ATENCIÓN: Contenido en Decadencia -->
<?php if (!empty($decay_content)) : ?>
<div class="card mb-4 shadow-sm" style="border-left: 4px solid #6b7280;">
<div class="card-header d-flex justify-content-between align-items-center" style="background-color: #6b7280; color: white;">
<h6 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-graph-down"></i>
<?php esc_html_e('ATENCIÓN: Contenido en Decadencia', 'roi-apu-search'); ?>
</h6>
<span class="badge bg-light text-dark"><?php echo count($decay_content); ?></span>
</div>
<div class="card-body p-0">
<p class="small text-muted px-3 pt-3 mb-2">
<?php esc_html_e('Posts que perdieron >20% clicks vs período anterior. Revisa si están desactualizados.', 'roi-apu-search'); ?>
</p>
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 50%;"><?php esc_html_e('Título', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Cambio', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Antes', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Ahora', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Acciones', 'roi-apu-search'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($decay_content as $post) : ?>
<tr>
<td>
<strong><?php echo esc_html(mb_strimwidth($post['post_title'], 0, 80, '...')); ?></strong>
</td>
<td class="text-center">
<span class="badge bg-danger"><?php echo esc_html($post['cambio_pct']); ?>%</span>
</td>
<td class="text-center"><?php echo esc_html($post['clicks_anterior']); ?></td>
<td class="text-center"><?php echo esc_html($post['clicks_actual']); ?></td>
<td class="text-center">
<div class="btn-group btn-group-sm" role="group">
<a href="<?php echo esc_url(admin_url('post.php?post=' . $post['post_id'] . '&action=edit')); ?>" target="_blank" class="btn btn-outline-primary" title="<?php esc_attr_e('Editar', 'roi-apu-search'); ?>">
<i class="bi bi-pencil"></i>
</a>
<a href="<?php echo esc_url($site_url . '/' . $post['post_name'] . '/'); ?>" target="_blank" class="btn btn-outline-secondary" title="<?php esc_attr_e('Ver', 'roi-apu-search'); ?>">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
<!-- 🟢 MANTENER: Tu Contenido Estrella -->
<?php if (!empty($contenido_estrella)) : ?>
<div class="card mb-4 shadow-sm" style="border-left: 4px solid #22c55e;">
<div class="card-header d-flex justify-content-between align-items-center" style="background-color: #22c55e; color: white;">
<h6 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-star-fill"></i>
<?php esc_html_e('MANTENER: Tu Contenido Estrella', 'roi-apu-search'); ?>
</h6>
<span class="badge bg-light text-dark"><?php echo count($contenido_estrella); ?></span>
</div>
<div class="card-body p-0">
<p class="small text-muted px-3 pt-3 mb-2">
<?php esc_html_e('Posts con más clicks. Mantén este contenido actualizado y optimizado.', 'roi-apu-search'); ?>
</p>
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 55%;"><?php esc_html_e('Título', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Clicks', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Pos. Prom.', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Acciones', 'roi-apu-search'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($contenido_estrella as $post) : ?>
<tr>
<td>
<strong><?php echo esc_html(mb_strimwidth($post['post_title'], 0, 80, '...')); ?></strong>
</td>
<td class="text-center">
<span class="badge bg-success"><?php echo esc_html(number_format((int)$post['clicks'])); ?></span>
</td>
<td class="text-center"><?php echo esc_html($post['pos_prom']); ?></td>
<td class="text-center">
<div class="btn-group btn-group-sm" role="group">
<a href="<?php echo esc_url(admin_url('post.php?post=' . $post['post_id'] . '&action=edit')); ?>" target="_blank" class="btn btn-outline-primary" title="<?php esc_attr_e('Editar', 'roi-apu-search'); ?>">
<i class="bi bi-pencil"></i>
</a>
<a href="<?php echo esc_url($site_url . '/' . $post['post_name'] . '/'); ?>" target="_blank" class="btn btn-outline-success" title="<?php esc_attr_e('Ver', 'roi-apu-search'); ?>">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
<!-- Posts infraposicionados -->
<?php if (!empty($infraposicionados)) : ?>
<div class="card mb-4 shadow-sm" style="border-left: 4px solid #f59e0b;">
<div class="card-header d-flex justify-content-between align-items-center" style="background-color: #f59e0b; color: white;">
<h6 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-arrow-up-circle"></i>
<?php esc_html_e('OPORTUNIDAD: Posts Infraposicionados', 'roi-apu-search'); ?>
</h6>
<span class="badge bg-light text-dark"><?php echo count($infraposicionados); ?></span>
</div>
<div class="card-body p-0">
<p class="small text-muted px-3 pt-3 mb-2">
<?php esc_html_e('Estos posts reciben clicks pero aparecen muy abajo. Considera mejorar su scoring.', 'roi-apu-search'); ?>
</p>
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 50%;"><?php esc_html_e('Título', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Clicks', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Pos. Prom.', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Acciones', 'roi-apu-search'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($infraposicionados as $post) : ?>
<tr>
<td>
<strong><?php echo esc_html(mb_strimwidth($post['post_title'], 0, 80, '...')); ?></strong>
</td>
<td class="text-center">
<span class="badge bg-warning text-dark"><?php echo esc_html(number_format((int)$post['clicks'])); ?></span>
</td>
<td class="text-center">
<span class="badge bg-secondary"><?php echo esc_html($post['pos_prom']); ?></span>
</td>
<td class="text-center">
<div class="btn-group btn-group-sm" role="group">
<a href="<?php echo esc_url(admin_url('post.php?post=' . $post['post_id'] . '&action=edit')); ?>" target="_blank" class="btn btn-outline-primary" title="<?php esc_attr_e('Editar', 'roi-apu-search'); ?>">
<i class="bi bi-pencil"></i>
</a>
<a href="<?php echo esc_url($site_url . '/' . $post['post_name'] . '/'); ?>" target="_blank" class="btn btn-outline-warning" title="<?php esc_attr_e('Ver', 'roi-apu-search'); ?>">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
<!-- ═══════════════ DATOS DETALLADOS ═══════════════ -->
<div class="mt-5 mb-4">
<h5 class="text-muted d-flex align-items-center gap-2">
<i class="bi bi-table"></i>
<?php esc_html_e('Datos Detallados', 'roi-apu-search'); ?>
</h5>
<hr class="mt-2">
</div>
<!-- 📊 Click Distribution -->
<?php if (!empty($click_distribution)) : ?>
<div class="card mb-4 shadow-sm">
<div class="card-header" style="background-color: #0E2337; color: white;">
<h6 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-bar-chart-fill"></i>
<?php esc_html_e('Distribución de Clicks por Posición', 'roi-apu-search'); ?>
</h6>
</div>
<div class="card-body">
<?php
$position_colors = [
'Pos 1' => '#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';
?>
<div class="mb-2">
<div class="d-flex justify-content-between mb-1">
<small><strong><?php echo esc_html($dist['posicion']); ?></strong></small>
<small><?php echo esc_html($dist['clicks']); ?> clicks (<?php echo esc_html($dist['porcentaje']); ?>%)</small>
</div>
<div class="progress" style="height: 20px;">
<div class="progress-bar" role="progressbar"
style="width: <?php echo esc_attr($dist['porcentaje']); ?>%; background-color: <?php echo esc_attr($color); ?>;"
aria-valuenow="<?php echo esc_attr($dist['porcentaje']); ?>"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- 📊 Top Búsquedas Table -->
<div class="card mb-4 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center" style="background-color: #1e3a5f; color: white;">
<h6 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-search"></i>
<?php esc_html_e('Top Búsquedas', 'roi-apu-search'); ?>
</h6>
<span class="badge bg-light text-dark"><?php echo esc_html($total_counts['total_searches']); ?> <?php esc_html_e('términos', 'roi-apu-search'); ?></span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th><?php esc_html_e('Término', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Búsquedas', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Clicks', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('CTR', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Resultados', 'roi-apu-search'); ?></th>
</tr>
</thead>
<tbody id="top-searches-body">
<?php foreach ($top_searches as $search) : ?>
<tr>
<td><strong><?php echo esc_html($search['q_term']); ?></strong></td>
<td class="text-center"><?php echo esc_html(number_format((int)$search['busquedas'])); ?></td>
<td class="text-center"><?php echo esc_html(number_format((int)$search['clicks'])); ?></td>
<td class="text-center">
<span class="badge <?php echo (float)$search['ctr'] > 0 ? 'bg-success' : 'bg-secondary'; ?>">
<?php echo esc_html($search['ctr']); ?>%
</span>
</td>
<td class="text-center"><?php echo esc_html(number_format((int)$search['resultados'])); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if ($total_counts['total_searches'] > 20) : ?>
<div class="card-footer text-center">
<button class="btn btn-sm btn-outline-secondary" id="load-more-searches" data-offset="20">
<i class="bi bi-plus-circle me-1"></i>
<?php esc_html_e('Cargar más', 'roi-apu-search'); ?>
</button>
</div>
<?php endif; ?>
</div>
<!-- 📄 Top Posts Clickeados Table -->
<div class="card mb-4 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center" style="background-color: #FF8600; color: white;">
<h6 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-text"></i>
<?php esc_html_e('Top Posts Clickeados', 'roi-apu-search'); ?>
</h6>
<span class="badge bg-light text-dark"><?php echo esc_html($total_counts['total_clicks']); ?> <?php esc_html_e('posts', 'roi-apu-search'); ?></span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th><?php esc_html_e('Título', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Clicks', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Pos. Prom.', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Acciones', 'roi-apu-search'); ?></th>
</tr>
</thead>
<tbody id="top-clicks-body">
<?php foreach ($top_clicks as $post) : ?>
<tr>
<td>
<strong><?php echo esc_html(mb_strimwidth($post['post_title'], 0, 80, '...')); ?></strong>
<br><small class="text-muted">/<?php echo esc_html($post['post_name']); ?>/</small>
</td>
<td class="text-center"><?php echo esc_html(number_format((int)$post['clicks'])); ?></td>
<td class="text-center">
<span class="badge <?php echo (float)$post['pos_prom'] <= 3 ? 'bg-success' : ((float)$post['pos_prom'] <= 5 ? 'bg-warning text-dark' : 'bg-secondary'); ?>">
<?php echo esc_html($post['pos_prom']); ?>
</span>
</td>
<td class="text-center">
<div class="btn-group btn-group-sm" role="group">
<a href="<?php echo esc_url(admin_url('post.php?post=' . $post['post_id'] . '&action=edit')); ?>" target="_blank" class="btn btn-outline-primary" title="<?php esc_attr_e('Editar', 'roi-apu-search'); ?>">
<i class="bi bi-pencil"></i>
</a>
<a href="<?php echo esc_url($site_url . '/' . $post['post_name'] . '/'); ?>" target="_blank" class="btn btn-outline-secondary" title="<?php esc_attr_e('Ver', 'roi-apu-search'); ?>">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if ($total_counts['total_clicks'] > 20) : ?>
<div class="card-footer text-center">
<button class="btn btn-sm btn-outline-secondary" id="load-more-clicks" data-offset="20">
<i class="bi bi-plus-circle me-1"></i>
<?php esc_html_e('Cargar más', 'roi-apu-search'); ?>
</button>
</div>
<?php endif; ?>
</div>
<!-- ❌ Búsquedas Sin Resultados Table -->
<?php if (!empty($all_zero_results)) : ?>
<div class="card mb-4 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center" style="background-color: #ef4444; color: white;">
<h6 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-x-circle"></i>
<?php esc_html_e('Búsquedas Sin Resultados', 'roi-apu-search'); ?>
</h6>
<span class="badge bg-light text-dark"><?php echo esc_html($total_counts['total_zero_results']); ?> <?php esc_html_e('términos', 'roi-apu-search'); ?></span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th><?php esc_html_e('Término', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Frecuencia', 'roi-apu-search'); ?></th>
<th class="text-center"><?php esc_html_e('Última búsqueda', 'roi-apu-search'); ?></th>
</tr>
</thead>
<tbody id="zero-results-body">
<?php foreach ($all_zero_results as $term) : ?>
<tr>
<td><strong><?php echo esc_html($term['q_term']); ?></strong></td>
<td class="text-center">
<span class="badge bg-danger"><?php echo esc_html(number_format((int)$term['frecuencia'])); ?></span>
</td>
<td class="text-center">
<small class="text-muted"><?php echo esc_html(date('d/m/Y H:i', strtotime($term['ultima_busqueda']))); ?></small>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if ($total_counts['total_zero_results'] > 20) : ?>
<div class="card-footer text-center">
<button class="btn btn-sm btn-outline-secondary" id="load-more-zero" data-offset="20">
<i class="bi bi-plus-circle me-1"></i>
<?php esc_html_e('Cargar más', 'roi-apu-search'); ?>
</button>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Export Card -->
<div class="card export-card mt-4 shadow-sm">
<div class="card-body text-center py-4">
<i class="bi bi-file-earmark-text fs-1 text-muted mb-2 d-block"></i>
<h6 class="mb-2"><?php esc_html_e('Exportar resumen para análisis IA', 'roi-apu-search'); ?></h6>
<p class="text-muted small mb-3">
<?php esc_html_e('Genera un Markdown completo con todas las recomendaciones', 'roi-apu-search'); ?>
</p>
<button class="btn text-white"
style="background-color: #FF8600;"
id="btn-export-md">
<i class="bi bi-clipboard me-2"></i>
<?php esc_html_e('Copiar Markdown', 'roi-apu-search'); ?>
</button>
</div>
</div>
</div>
</div>
</div>
<script>
window.roiApuDashboardData = {
period: <?php echo (int) $days; ?>,
generated: '<?php echo esc_js(current_time('Y-m-d H:i')); ?>',
siteUrl: '<?php echo esc_js($site_url); ?>',
kpis: {
totalBusquedas: '<?php echo esc_js($total_busquedas); ?>',
ctr: '<?php echo esc_js($ctr); ?>',
sinResultados: '<?php echo esc_js($sin_resultados); ?>',
posProm: '<?php echo esc_js($pos_prom); ?>'
},
// v2: Urgent - Zero Results
zeroResults: <?php echo wp_json_encode(array_map(function($term) {
return [
'term' => $term['q_term'],
'frecuencia' => (int) $term['frecuencia']
];
}, $zero_results)); ?>,
// v2: CTR Zero
ctrZero: <?php echo wp_json_encode(array_map(function($term) {
return [
'term' => $term['q_term'],
'busquedas' => (int) $term['busquedas'],
'resultados' => (int) $term['resultados']
];
}, $ctr_zero)); ?>,
// v2: Quick Wins
quickWins: <?php echo wp_json_encode(array_map(function($term) {
return [
'term' => $term['q_term'],
'busquedas' => (int) $term['busquedas'],
'ctr' => (float) $term['ctr'],
'posProm' => (float) $term['pos_prom']
];
}, $quick_wins)); ?>,
// v2: Contenido Estrella
contenidoEstrella: <?php echo wp_json_encode(array_map(function($post) use ($site_url) {
return [
'postId' => (int) $post['post_id'],
'title' => $post['post_title'],
'url' => $site_url . '/' . $post['post_name'] . '/',
'clicks' => (int) $post['clicks'],
'posProm' => (float) $post['pos_prom']
];
}, $contenido_estrella)); ?>,
// v2: Decay Content
decayContent: <?php echo wp_json_encode(array_map(function($post) use ($site_url) {
return [
'postId' => (int) $post['post_id'],
'title' => $post['post_title'],
'url' => $site_url . '/' . $post['post_name'] . '/',
'clicksActual' => (int) $post['clicks_actual'],
'clicksAnterior' => (int) $post['clicks_anterior'],
'cambioPct' => (float) $post['cambio_pct']
];
}, $decay_content)); ?>,
// v2: Click Distribution
clickDistribution: <?php echo wp_json_encode(array_map(function($dist) {
return [
'posicion' => $dist['posicion'],
'clicks' => (int) $dist['clicks'],
'porcentaje' => (float) $dist['porcentaje']
];
}, $click_distribution)); ?>,
// Legacy
infraposicionados: <?php echo wp_json_encode(array_map(function($post) {
return [
'postId' => (int) $post['post_id'],
'title' => $post['post_title'],
'clicks' => (int) $post['clicks'],
'posProm' => (float) $post['pos_prom']
];
}, $infraposicionados)); ?>,
// Totals for pagination
totals: {
searches: <?php echo (int) $total_counts['total_searches']; ?>,
clicks: <?php echo (int) $total_counts['total_clicks']; ?>,
zeroResults: <?php echo (int) $total_counts['total_zero_results']; ?>
}
};
// Hide loading, show content
document.getElementById('loadingState').style.display = 'none';
</script>
<?php
}
}

View File

@@ -0,0 +1,552 @@
<?php
/**
* Metrics Repository for Analytics Dashboard
*
* @package ROI_APU_Search
* @since 1.2.0
*/
declare(strict_types=1);
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
/**
* Repository class for dashboard metrics queries
*/
final class ROI_APU_Metrics_Repository
{
private PDO $pdo;
private string $prefix;
/**
* Constructor
*
* @param PDO $pdo Database connection
* @param string $prefix Table prefix
*/
public function __construct(PDO $pdo, string $prefix)
{
$this->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<array{q_term: string, busquedas: int, resultados: int}>
*/
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<array{post_id: int, post_title: string, clicks: int, pos_prom: float}>
*/
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<array{q_term: string, busquedas: int, clicks: int, ctr: float, resultados: int}>
*/
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<array{post_id: int, post_title: string, post_name: string, clicks: int, busquedas_con_click: int, pos_prom: float}>
*/
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<array{q_term: string, frecuencia: int, ultima_busqueda: string}>
*/
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<array{q_term: string, busquedas: int, clicks: int, ctr: float, pos_prom: float}>
*/
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<array{post_id: int, post_title: string, post_name: string, clicks: int, busquedas_con_click: int, pos_prom: float}>
*/
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<array{posicion: string, clicks: int, porcentaje: float}>
*/
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<array{post_id: int, post_title: string, post_name: string, clicks_actual: int, clicks_anterior: int, cambio_pct: float}>
*/
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;
}
}

View File

@@ -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();
}
}
/**

11
sql/create-indices.sql Normal file
View File

@@ -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);