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:
138
admin/assets/dashboard.css
Normal file
138
admin/assets/dashboard.css
Normal 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
233
admin/assets/dashboard.js
Normal 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
|
||||
};
|
||||
|
||||
})();
|
||||
874
admin/class-analytics-dashboard.php
Normal file
874
admin/class-analytics-dashboard.php
Normal 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
|
||||
}
|
||||
}
|
||||
552
admin/class-metrics-repository.php
Normal file
552
admin/class-metrics-repository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user