feat: analytics propio — historial diario, gráfica 30 días, top páginas en dashboard WP

This commit is contained in:
2026-06-05 16:54:02 -05:00
parent 74f3dc6230
commit c46f1fd862
3 changed files with 168 additions and 22 deletions
+12
View File
@@ -4,6 +4,18 @@ Formato: `X.Y.Z` según `VERSIONADO.md`
--- ---
## [1.2.0] — 2026-06-05
### feat: Analytics propio — historial diario, gráfica 30 días, top páginas
- **Tabla `cv_cv_visits`** creada en BD dev (`.20`) y prod (`.32`): registra visitas únicas por día y URL.
- **Migración automática** del acumulado anterior (`persona/cvolio/lumina/apex_total_visits`) a la nueva tabla.
- **Registro de visitas** en `template_redirect` — deduplicado por IP+URI+hora con transient, solo para visitantes no autenticados.
- **Limpieza semanal** vía WP-Cron: elimina registros con más de 365 días.
- **Widget de dashboard** `📊 CV Analytics — Estadísticas` con: 4 tarjetas (Hoy / 7d / 30d / Total), gráfica de barras CSS últimos 30 días sin librerías externas, top 5 páginas con porcentaje, nota LOPDP al pie.
---
## [1.1.0] — 2026-06-05 ## [1.1.0] — 2026-06-05
### feat: LOPDP Ecuador completo ### feat: LOPDP Ecuador completo
+1 -1
View File
@@ -1 +1 @@
1.1.0 1.2.0
@@ -459,34 +459,168 @@ function pflores_lopd_banner(): void
<?php <?php
} }
// C) Widget de visitas en el dashboard // C1) Crear tabla si no existe (admin_init)
add_action('wp_dashboard_setup', 'pflores_visitas_widget'); add_action('admin_init', function () {
function pflores_visitas_widget(): void if (get_option('pflores_visits_table_v1')) {
return;
}
global $wpdb;
$table = $wpdb->prefix . 'cv_visits';
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta("CREATE TABLE IF NOT EXISTS `{$table}` (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
fecha DATE NOT NULL,
pagina VARCHAR(255) NOT NULL DEFAULT '/',
visitas INT UNSIGNED NOT NULL DEFAULT 1,
UNIQUE KEY fecha_pagina (fecha, pagina)
) {$wpdb->get_charset_collate()};");
$old = array_sum(array_map(
fn ($k) => (int) get_option("{$k}_total_visits", 0),
['persona', 'cvolio', 'lumina', 'apex']
));
if ($old > 0) {
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO `{$table}` (fecha,pagina,visitas) VALUES (%s,'/',%d)",
'2026-01-01',
$old
));
}
update_option('pflores_visits_table_v1', true);
});
// C2) Registrar visita (template_redirect)
add_action('template_redirect', function () {
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
return;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {
return;
}
if (is_user_logged_in() && current_user_can('manage_options')) {
return;
}
if (! is_front_page() && ! is_home() && ! is_singular()) {
return;
}
if (! get_option('pflores_visits_table_v1')) {
return;
}
$uri = strtok(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '/')), '?');
$key = 'pflores2_' . md5($uri . '|' . pflores_client_ip() . '|' . gmdate('Y-m-d-H'));
if (get_transient($key)) {
return;
}
set_transient($key, '1', HOUR_IN_SECONDS);
global $wpdb;
$wpdb->query($wpdb->prepare(
"INSERT INTO `{$wpdb->prefix}cv_visits` (fecha,pagina,visitas) VALUES (CURDATE(),%s,1)
ON DUPLICATE KEY UPDATE visitas=visitas+1",
$uri
));
});
function pflores_client_ip(): string
{
foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'] as $k) {
if (! empty($_SERVER[$k])) {
return trim(explode(',', (string) $_SERVER[$k])[0]);
}
}
return '0.0.0.0';
}
// C3) Limpieza semanal (cron)
add_action('init', function () {
if (! wp_next_scheduled('pflores_visits_cleanup')) {
wp_schedule_event(time(), 'weekly', 'pflores_visits_cleanup');
}
});
add_action('pflores_visits_cleanup', function () {
global $wpdb;
$wpdb->query("DELETE FROM `{$wpdb->prefix}cv_visits`
WHERE fecha < DATE_SUB(CURDATE(), INTERVAL 365 DAY)");
});
// C4) Widget de Analytics en el dashboard
add_action('wp_dashboard_setup', 'pflores_analytics_widget');
function pflores_analytics_widget(): void
{ {
wp_add_dashboard_widget( wp_add_dashboard_widget(
'pflores_visitas_totales', 'pflores_analytics',
'CV Patricia Flores — Estadísticas de visitas', '📊 CV Analytics — Estadísticas',
'pflores_render_visitas_widget' 'pflores_render_analytics_widget'
); );
} }
function pflores_render_visitas_widget(): void
function pflores_render_analytics_widget(): void
{ {
$temas = [ global $wpdb;
'persona-cv' => (int) get_option('persona_total_visits', 0), $t = $wpdb->prefix . 'cv_visits';
'cvolio' => (int) get_option('cvolio_total_visits', 0),
'lumina' => (int) get_option('lumina_total_visits', 0), $hoy = (int) $wpdb->get_var("SELECT COALESCE(SUM(visitas),0) FROM `{$t}` WHERE fecha=CURDATE()");
'apex-cv' => (int) get_option('apex_total_visits', 0), $sem = (int) $wpdb->get_var("SELECT COALESCE(SUM(visitas),0) FROM `{$t}` WHERE fecha>=DATE_SUB(CURDATE(),INTERVAL 7 DAY)");
$mes = (int) $wpdb->get_var("SELECT COALESCE(SUM(visitas),0) FROM `{$t}` WHERE fecha>=DATE_SUB(CURDATE(),INTERVAL 30 DAY)");
$total = (int) $wpdb->get_var("SELECT COALESCE(SUM(visitas),0) FROM `{$t}`");
$dias30 = $wpdb->get_results(
"SELECT fecha, SUM(visitas) AS v FROM `{$t}`
WHERE fecha>=DATE_SUB(CURDATE(),INTERVAL 30 DAY)
GROUP BY fecha ORDER BY fecha ASC",
ARRAY_A
) ?: [];
$top = $wpdb->get_results(
"SELECT pagina, SUM(visitas) AS v FROM `{$t}`
GROUP BY pagina ORDER BY SUM(visitas) DESC LIMIT 5",
ARRAY_A
) ?: [];
// Tarjetas resumen
$cards = [
['Hoy', $hoy, '#1a7a8a'],
['7 días', $sem, '#1a7a8a'],
['30 días', $mes, '#1a7a8a'],
['Total', $total, '#c0392b'],
]; ];
$total = array_sum($temas); echo '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px">';
echo '<table style="width:100%;border-collapse:collapse;font-size:.9rem">'; foreach ($cards as [$label, $num, $color]) {
foreach ($temas as $slug => $visitas) { echo '<div style="background:#f8f9fa;border-top:3px solid ' . esc_attr($color) . ';border-radius:4px;padding:8px;text-align:center">';
echo '<tr><td style="padding:4px 8px">' . esc_html($slug) . '</td>'; echo '<div style="font-size:1.4rem;font-weight:700;color:' . esc_attr($color) . '">' . esc_html((string) $num) . '</div>';
echo '<td style="padding:4px 8px;text-align:right;font-weight:600">' . esc_html((string) $visitas) . '</td></tr>'; echo '<div style="font-size:.72rem;color:#555;margin-top:2px">' . esc_html($label) . '</div>';
echo '</div>';
}
echo '</div>';
// Gráfica de barras — últimos 30 días
if (! empty($dias30)) {
$maxV = max(array_column($dias30, 'v')) ?: 1;
echo '<div style="margin-bottom:10px">';
echo '<p style="font-size:.72rem;color:#888;margin-bottom:4px;text-transform:uppercase;letter-spacing:.06em">Últimos 30 días</p>';
echo '<div style="display:flex;align-items:flex-end;height:65px;gap:2px;overflow:hidden">';
foreach ($dias30 as $row) {
$h = max(2, (int) round(((int) $row['v'] / $maxV) * 60));
$tip = esc_attr($row['fecha'] . ': ' . $row['v']);
echo '<div title="' . $tip . '" style="flex:1;height:' . $h . 'px;background:#1B3A5C;border-radius:2px 2px 0 0;min-width:2px;cursor:default"></div>';
}
echo '</div></div>';
}
// Top 5 páginas
if (! empty($top)) {
echo '<p style="font-size:.72rem;color:#888;margin-bottom:4px;text-transform:uppercase;letter-spacing:.06em">Top páginas</p>';
echo '<table style="width:100%;border-collapse:collapse;font-size:.82rem">';
foreach ($top as $row) {
$pct = $total > 0 ? round(((int) $row['v'] / $total) * 100) : 0;
echo '<tr>';
echo '<td style="padding:3px 4px;color:#333">' . esc_html($row['pagina']) . '</td>';
echo '<td style="padding:3px 4px;text-align:right;font-weight:600;color:#1B3A5C">' . esc_html((string) $row['v']) . '</td>';
echo '<td style="padding:3px 4px;text-align:right;color:#888;font-size:.75rem">' . esc_html((string) $pct) . '%</td>';
echo '</tr>';
} }
echo '<tr style="border-top:1px solid #ccc"><td style="padding:6px 8px;font-weight:700">Total</td>';
echo '<td style="padding:6px 8px;text-align:right;font-weight:700">' . esc_html((string) $total) . '</td></tr>';
echo '</table>'; echo '</table>';
echo '<p style="margin-top:8px;font-size:.78rem;color:#666">Visitas únicas por hora (LOPDP — datos anónimos)</p>'; }
echo '<p style="margin-top:8px;font-size:.72rem;color:#999">Visitas únicas/hora · LOPDP — datos anónimos</p>';
} }
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────