'
);
add_menu_page(
__( 'PulseViews', 'pulseviews' ),
__( 'PulseViews', 'pulseviews' ),
'manage_options',
'pulseviews',
[ $this, 'render_dashboard' ],
$icon,
30
);
add_submenu_page( 'pulseviews', __( 'Dashboard', 'pulseviews' ), __( 'Dashboard', 'pulseviews' ), 'manage_options', 'pulseviews', [ $this, 'render_dashboard' ] );
add_submenu_page( 'pulseviews', __( 'Settings', 'pulseviews' ), __( 'Settings', 'pulseviews' ), 'manage_options', 'pulseviews-settings', [ new SettingsPage(), 'render' ] );
}
public function render_dashboard(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'pulseviews' ) );
}
$repo = new \PulseViews\Database\ViewsRepository();
$totals = $repo->get_site_totals();
$top = $repo->get_top_posts( 8, '', 'total' );
$series = $this->get_calendar_month_series();
$top_posts = array_map( static function ( array $item ): array {
$post = get_post( $item['post_id'] );
return [
'post_id' => $item['post_id'],
'views' => $item['views'],
'title' => $post ? get_the_title( $post ) : "Post #{$item['post_id']}",
'edit_link' => $post ? ( get_edit_post_link( $post, 'raw' ) ?: '' ) : '',
'type' => $post ? $post->post_type : '',
];
}, $top );
$max_views = ! empty( $top_posts ) ? max( array_column( $top_posts, 'views' ) ) : 1;
?>
👁
fmt( $totals['today'] ) ); ?>
📅
fmt( $totals['week'] ) ); ?>
📈
fmt( $totals['month'] ) ); ?>
render_posts_table( $top_posts, $max_views ); ?>
render_pro_sections(); ?>
$this->get_hourly_series() ] );
return;
}
if ( 'month' === $days_raw ) {
wp_send_json( [ 'series' => $this->get_calendar_month_series() ] );
return;
}
$days = min( 365, max( 2, (int) $days_raw ) );
wp_send_json( [ 'series' => $this->get_site_series( $days ) ] );
}
if ( 'top_posts' === $type ) {
$period = in_array( sanitize_key( wp_unslash( $_GET['period'] ?? '' ) ), [ 'total', 'today', 'week', 'month' ], true )
? sanitize_key( $_GET['period'] )
: 'total';
$repo = new ViewsRepository();
$top = $repo->get_top_posts( 8, '', $period );
$top_posts = array_map( static function ( array $item ): array {
$post = get_post( $item['post_id'] );
return [
'post_id' => $item['post_id'],
'views' => $item['views'],
'title' => $post ? get_the_title( $post ) : "Post #{$item['post_id']}",
'edit_link' => $post ? ( get_edit_post_link( $post, 'raw' ) ?: '' ) : '',
'type' => $post ? $post->post_type : '',
];
}, $top );
$max = ! empty( $top_posts ) ? max( array_column( $top_posts, 'views' ) ) : 1;
ob_start();
$this->render_posts_table( $top_posts, $max );
wp_send_json( [ 'html' => ob_get_clean() ] );
}
wp_die( '', 400 );
}
private function get_dashboard_css(): string {
return <<<'PVDASHCSS'
#pv-app,#pv-app *{box-sizing:border-box;font-family:'Inter',system-ui,sans-serif}
#pv-app{
--surface:#f8f9ff;
--surface-lowest:#ffffff;
--surface-low:#f2f3f9;
--surface-container:#eceef4;
--surface-high:#e6e8ee;
--surface-highest:#e1e2e8;
--outline:#717881;
--outline-variant:#c1c7d2;
--on-surface:#191c20;
--on-surface-var:#414750;
--primary:#005891;
--primary-container:#2271b1;
--on-primary:#ffffff;
--on-primary-container:#e9f1ff;
--secondary:#1b6296;
--secondary-container:#8bc6ff;
--success:#166534;
--success-bg:#dcfce7;
--error:#ba1a1a;
--error-bg:#ffdad6;
--shadow:0px 2px 4px rgba(0,0,0,0.05);
background:transparent;color:var(--on-surface);padding:0;margin:0;-webkit-font-smoothing:antialiased
}
.pv-wrap{max-width:1080px;margin:0;padding:24px 0 64px}
.pv-hd{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:12px}
.pv-hd h1{font-size:24px;font-weight:600;letter-spacing:-0.02em;line-height:32px;margin:0;color:var(--on-surface)}
.pv-hd p{font-size:13px;color:var(--on-surface-var);margin:4px 0 0;line-height:20px}
.pv-live{display:inline-flex;align-items:center;gap:7px;background:#dcfce7;border:1px solid #bbf7d0;color:var(--success);font-size:10px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:5px 12px;border-radius:9999px;white-space:nowrap;align-self:flex-start}
.pv-live::before{content:'';width:6px;height:6px;background:#16a34a;border-radius:50%;animation:pvpulse 1.6s ease infinite}
@keyframes pvpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.7)}}
.pv-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-bottom:20px}
@media(max-width:900px){.pv-cards{grid-template-columns:repeat(2,1fr)}}
@media(max-width:560px){.pv-cards{grid-template-columns:1fr}}
.pv-card{background:var(--surface-lowest);border:1px solid var(--outline-variant);border-radius:12px;padding:24px;position:relative;box-shadow:var(--shadow);transition:box-shadow .2s}
.pv-card:hover{box-shadow:0px 4px 12px rgba(0,0,0,0.08)}
.pv-card-hd{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}
.pv-cl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:var(--on-surface-var);line-height:16px}
.pv-card-icon{width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:18px;line-height:1}
.pv-card-body{display:flex;align-items:baseline;gap:8px;margin-bottom:16px}
.pv-cv{font-size:32px;font-weight:700;letter-spacing:-0.03em;line-height:40px;font-variant-numeric:tabular-nums;color:var(--on-surface)}
.pv-cc{display:flex;align-items:center;gap:2px;font-size:13px;font-weight:700}
.pv-cc.up{color:#16a34a}.pv-cc.dn{color:var(--error)}
.pv-card-bar{height:4px;width:100%;background:var(--surface-container);border-radius:9999px;overflow:hidden}
.pv-card-bar-fill{height:100%;border-radius:9999px;background:var(--primary)}
.pv-card-bar-fill.sec{background:var(--secondary)}
.pv-main-grid{display:grid;grid-template-columns:1fr 340px;gap:20px;margin-bottom:20px}
@media(max-width:1020px){.pv-main-grid{grid-template-columns:1fr}}
.pv-panel{background:var(--surface-lowest);border:1px solid var(--outline-variant);border-radius:12px;box-shadow:var(--shadow);overflow:hidden;margin-bottom:0}
.pv-panel-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid var(--outline-variant);background:var(--surface)}
.pv-panel-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:var(--on-surface-var)}
.pv-panel-body{padding:24px}
.pv-chart-wrap{height:300px;position:relative}
.pv-chart-btns{display:flex;gap:4px}
.pv-chart-btn{font-size:11px;font-weight:700;padding:4px 10px;border-radius:4px;border:none;cursor:pointer;transition:all .15s;font-family:inherit;background:transparent;color:var(--on-surface-var)}
.pv-chart-btn.on{background:var(--on-primary-container);color:var(--primary);font-weight:700}
.pv-chart-btn:hover:not(.on){background:var(--surface-container)}
.pv-sidebar{display:flex;flex-direction:column;gap:20px}
.pv-health{display:flex;align-items:center;gap:16px}
.pv-health-ring{position:relative;width:64px;height:64px;flex-shrink:0}
.pv-health-ring svg{width:100%;height:100%;transform:rotate(-90deg)}
.pv-health-label{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;transform:rotate(0deg)}
.pv-cache-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:13px}
.pv-cache-row+.pv-cache-row{border-top:1px solid var(--surface-high)}
.pv-cache-ok{font-weight:600;color:var(--primary)}
.pv-realtime{background:var(--primary);color:var(--on-primary);border-radius:12px;padding:24px;position:relative;overflow:hidden;box-shadow:0 4px 16px rgba(0,88,145,.3)}
.pv-realtime-hd{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:11px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;opacity:.85}
.pv-realtime-dot{width:8px;height:8px;background:#fff;border-radius:50%;animation:pvpulse 1.5s ease infinite;box-shadow:0 0 8px rgba(255,255,255,.8)}
.pv-realtime-num{font-size:32px;font-weight:700;letter-spacing:-0.03em;line-height:1;margin-bottom:6px;font-variant-numeric:tabular-nums}
.pv-realtime-sub{font-size:13px;opacity:.85}
.pv-realtime-bg{position:absolute;bottom:-16px;right:-16px;font-size:80px;opacity:.1;line-height:1}
.pv-table-wrap{overflow-x:auto}
.pv-table{width:100%;border-collapse:collapse;font-size:13px}
.pv-table thead th{padding:0 0 12px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:var(--outline);border-bottom:1px solid var(--outline-variant);text-align:left}
.pv-table tbody tr{border-bottom:1px solid var(--surface-high);transition:background .15s;cursor:pointer}
.pv-table tbody tr:last-child{border-bottom:none}
.pv-table tbody tr:hover{background:var(--surface-low)}
.pv-table td{padding:14px 0}
.pv-table-title{font-weight:600;color:var(--on-surface);font-size:13px}
.pv-table-views{font-size:13px;color:var(--on-surface-var);font-variant-numeric:tabular-nums}
.pv-table-trend{font-size:12px;font-weight:700}
.pv-table-trend.up{color:#16a34a}.pv-table-trend.dn{color:var(--error)}
.pv-bottom-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px}
@media(max-width:760px){.pv-bottom-grid{grid-template-columns:1fr}}
.pv-mini-bar-bg{height:4px;background:var(--surface-highest);border-radius:2px;overflow:hidden;flex-shrink:0;width:48px}
.pv-mini-bar-fill{height:100%;border-radius:2px;background:var(--primary)}
.pv-author-row{display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--surface-high)}
.pv-author-row:last-child{border-bottom:none}
.pv-avatar{width:40px;height:40px;border-radius:9999px;background:var(--secondary-container);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;color:var(--on-surface);flex-shrink:0}
.pv-author-name{font-size:13px;font-weight:600;color:var(--on-surface)}
.pv-author-views{font-size:11px;color:var(--outline)}
.pv-device-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;text-align:center}
.pv-device-icon{font-size:24px;margin-bottom:4px}
.pv-device-pct{font-size:12px;font-weight:700;color:var(--on-surface)}
.pv-empty{text-align:center;padding:48px 20px}
.pv-empty-icon{font-size:38px;margin-bottom:10px}
.pv-empty-t{font-size:14px;font-weight:600;color:var(--on-surface-var);margin-bottom:4px}
.pv-empty-s{font-size:12px;color:var(--outline);line-height:1.6;max-width:300px;margin:0 auto}
#pv-tip{position:absolute;background:var(--surface-lowest);border:1px solid var(--outline-variant);border-radius:8px;padding:8px 12px;font-size:12px;pointer-events:none;opacity:0;transition:opacity .15s;white-space:nowrap;z-index:9;box-shadow:0 4px 16px rgba(0,0,0,.12)}
#pv-tip strong{color:var(--on-surface);display:block;font-variant-numeric:tabular-nums;font-weight:600}
#pv-tip span{color:var(--on-surface-var);font-size:10px}
.pv-badge-green{display:inline-flex;align-items:center;font-size:10px;font-weight:700;padding:2px 8px;border-radius:9999px;background:#dcfce7;color:#166534;letter-spacing:0.03em}
.pv-pro-section-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:var(--outline);margin:24px 0 16px;display:flex;align-items:center;gap:10px}
.pv-pro-section-label::after{content:'';flex:1;height:1px;background:var(--outline-variant)}
.pv-pro-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-bottom:20px}
@media(max-width:900px){.pv-pro-grid{grid-template-columns:1fr}}
.pv-pro-card{background:var(--surface-lowest);border:1px solid var(--outline-variant);border-radius:12px;overflow:hidden;position:relative;box-shadow:var(--shadow)}
.pv-pro-card-inner{padding:24px;filter:blur(2px);pointer-events:none;user-select:none;opacity:.5}
.pv-pro-card-hd{display:flex;align-items:center;gap:10px;margin-bottom:16px}
.pv-pro-card-icon{width:36px;height:36px;border-radius:8px;background:var(--on-primary-container);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
.pv-pro-card-title{font-size:14px;font-weight:600;color:var(--on-surface)}
.pv-pro-card-sub{font-size:12px;color:var(--on-surface-var);margin-top:1px}
.pv-pro-card-desc{font-size:12px;color:var(--on-surface-var);line-height:1.5;margin:12px 0}
.pv-pro-fake-row{height:10px;background:var(--surface-container);border-radius:3px;margin-bottom:8px}
.pv-pro-fake-chart{height:64px;background:var(--surface-container);border-radius:6px;overflow:hidden;margin-bottom:12px}
.pv-pro-fake-bars{display:flex;align-items:flex-end;gap:5px;height:56px;padding:0 6px}
.pv-pro-bar{background:var(--outline-variant);border-radius:3px 3px 0 0;flex:1}
.pv-pro-lock{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;z-index:2;background:rgba(248,249,255,.7);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
.pv-pro-lock-badge{background:var(--primary-container);color:var(--on-primary);font-size:10px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:4px 12px;border-radius:9999px}
.pv-pro-lock-text{font-size:13px;font-weight:600;color:var(--on-surface);text-align:center;line-height:1.4;max-width:180px}
.pv-pro-lock-sub{font-size:11px;color:var(--on-surface-var);text-align:center;max-width:190px;line-height:1.5}
.pv-pro-lock-btn{display:inline-flex;align-items:center;gap:6px;background:var(--primary-container);color:var(--on-primary);font-size:12px;font-weight:600;padding:8px 18px;border-radius:8px;text-decoration:none;transition:opacity .15s}
.pv-pro-lock-btn:hover{opacity:.88;color:#fff}
.pv-pro-unlocked .pv-pro-card-inner{filter:none;opacity:1;pointer-events:auto;user-select:auto}
.pv-pro-unlocked{border-color:var(--outline-variant)}
.pv-pro-open-btn{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;color:var(--primary-container);text-decoration:none;padding:7px 14px;border-radius:8px;border:1px solid var(--outline-variant);background:var(--surface-low);transition:all .15s;margin-top:8px}
.pv-pro-open-btn:hover{background:var(--on-primary-container);border-color:var(--primary-container);color:var(--primary)}
.pv-pro-banner{display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;background:var(--on-primary-container);border:1px solid var(--outline-variant);border-radius:12px;padding:20px 24px;margin-bottom:20px;box-shadow:var(--shadow)}
.pv-pro-active-banner{background:#f0fdf4;border-color:#bbf7d0}
.pv-pro-banner-l{display:flex;align-items:center;gap:16px}
.pv-pro-banner-icon{font-size:32px;flex-shrink:0}
.pv-pro-banner-title{font-size:15px;font-weight:600;color:var(--on-surface);margin-bottom:4px}
.pv-pro-banner-desc{font-size:12px;color:var(--on-surface-var);line-height:1.6}
.pv-pro-banner-desc ul{margin:4px 0 0 14px;list-style:disc}
.pv-pro-banner-desc li{margin-bottom:2px}
.pv-pro-banner-desc a{color:var(--primary);font-weight:600}
.pv-pro-banner-btn{display:inline-flex;align-items:center;gap:8px;background:var(--primary-container);color:var(--on-primary);font-size:13px;font-weight:700;padding:10px 22px;border-radius:8px;text-decoration:none;white-space:nowrap;transition:opacity .15s,box-shadow .15s;flex-shrink:0}
.pv-pro-banner-btn:hover{opacity:.9;color:#fff;box-shadow:0 4px 12px rgba(34,113,177,.35)}
.pv-pro-active-btn{background:#166534}
/* WP Dashboard Widget Styles */
.pv-widget { padding: 4px 0; font-size: 13px; }
.pv-widget-empty { text-align: center; padding: 20px; color: #50575e; }
.pv-widget-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f1; }
.pv-widget-stat-label { font-size: 11px; text-transform: uppercase; color: #646970; font-weight: 600; margin-bottom: 2px; }
.pv-widget-stat-val { font-size: 20px; font-weight: 700; color: #1d2327; }
.pv-widget-section-hd { font-size: 11px; font-weight: 700; text-transform: uppercase; color: #646970; margin-bottom: 10px; }
.pv-widget-post { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.pv-widget-post-title { flex: 1; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; color: #2271b1; text-decoration: none; font-weight: 500; }
.pv-widget-post-title:hover { text-decoration: underline; }
.pv-widget-post-bar { width: 60px; height: 4px; background: #f0f0f1; border-radius: 2px; overflow: hidden; flex-shrink: 0; }
.pv-widget-post-bar-fill { height: 100%; background: #2271b1; border-radius: 2px; }
.pv-widget-post-views { font-size: 12px; font-weight: 600; color: #50575e; min-width: 32px; text-align: right; }
.pv-widget-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; padding-top: 10px; border-top: 1px solid #f0f0f1; font-size: 12px; }
.pv-widget-live { color: #166534; background: #dcfce7; font-weight: 700; font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; }
.pv-widget-footer a { color: #2271b1; text-decoration: none; font-weight: 600; }
PVDASHCSS;
}
private function get_dashboard_js(): string {
return <<<'PVJS'
(function(){
function pvInit(){
var cfg = window.pulseviewsDashData || window.pvDashData || {};
var series = cfg.series || [];
var ajaxUrl = cfg.ajaxUrl || '';
var nonce = cfg.nonce || '';
var activeDays = 30;
var wrap = document.getElementById('pv-chart-wrap');
var tip = document.getElementById('pv-tip');
var tipV = document.getElementById('pv-tip-v');
var tipD = document.getElementById('pv-tip-d');
if (!wrap) return;
function getAccent(){
var app = document.getElementById('pv-app');
if (!app) return '#2271b1';
var col = getComputedStyle(app).getPropertyValue('--primary-container').trim();
return col || '#2271b1';
}
function buildChart(pts){
var old = wrap.querySelector('svg');
if (old) old.remove();
if (!pts || pts.length < 2) return;
var W = wrap.offsetWidth || wrap.parentElement.offsetWidth || 600;
var H = 260, P = 14;
var vals = pts.map(function(s){ return parseInt(s.views, 10) || 0; });
var mx = Math.max.apply(null, vals.concat([1]));
var ac = getAccent();
var ns = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible';
var defs = document.createElementNS(ns, 'defs');
var grad = document.createElementNS(ns, 'linearGradient');
grad.id = 'pvChartGrad';
grad.setAttribute('x1','0'); grad.setAttribute('y1','0');
grad.setAttribute('x2','0'); grad.setAttribute('y2','1');
function mkStop(offset, opacity){
var s = document.createElementNS(ns, 'stop');
s.setAttribute('offset', offset);
s.setAttribute('stop-color', ac);
s.setAttribute('stop-opacity', opacity);
return s;
}
grad.appendChild(mkStop('0%', '0.18'));
grad.appendChild(mkStop('100%', '0'));
defs.appendChild(grad);
svg.appendChild(defs);
[0.25, 0.5, 0.75, 1].forEach(function(f){
var y = H - P - f * (H - P * 2);
var line = document.createElementNS(ns, 'line');
line.setAttribute('x1', P); line.setAttribute('x2', W - P);
line.setAttribute('y1', y); line.setAttribute('y2', y);
line.setAttribute('stroke', 'rgba(0,0,0,0.05)');
line.setAttribute('stroke-width', '1');
svg.appendChild(line);
});
var coords = pts.map(function(s, i){
var v = parseInt(s.views, 10) || 0;
return {
x: P + (i / (pts.length - 1)) * (W - P * 2),
y: H - P - (v / mx) * (H - P * 2),
v: v,
d: s.date || ''
};
});
var pathD = 'M' + coords[0].x + ',' + coords[0].y;
for (var i = 0; i < coords.length - 1; i++){
var cx = (coords[i].x + coords[i+1].x) / 2;
pathD += ' C' + cx + ',' + coords[i].y + ' ' + cx + ',' + coords[i+1].y + ' ' + coords[i+1].x + ',' + coords[i+1].y;
}
var area = document.createElementNS(ns, 'path');
area.setAttribute('d', pathD + ' L' + (W - P) + ',' + H + ' L' + P + ',' + H + ' Z');
area.setAttribute('fill', 'url(#pvChartGrad)');
svg.appendChild(area);
var stroke = document.createElementNS(ns, 'path');
stroke.setAttribute('d', pathD);
stroke.setAttribute('fill', 'none');
stroke.setAttribute('stroke', ac);
stroke.setAttribute('stroke-width', '2');
stroke.setAttribute('stroke-linejoin', 'round');
stroke.setAttribute('stroke-linecap', 'round');
svg.appendChild(stroke);
var colW = (W - P * 2) / Math.max(coords.length, 1);
coords.forEach(function(pt){
var rect = document.createElementNS(ns, 'rect');
rect.setAttribute('x', pt.x - colW / 2);
rect.setAttribute('y', 0);
rect.setAttribute('width', colW);
rect.setAttribute('height', H);
rect.setAttribute('fill', 'transparent');
rect.style.cursor = 'crosshair';
rect.addEventListener('mouseenter', function(){
if (tipV) tipV.textContent = pt.v.toLocaleString() + ' views';
if (tipD) tipD.textContent = pt.d;
if (tip) tip.style.opacity = '1';
var dot = svg.querySelector('.pv-dot');
if (dot) dot.remove();
var c = document.createElementNS(ns, 'circle');
c.setAttribute('cx', pt.x); c.setAttribute('cy', pt.y);
c.setAttribute('r', '5');
c.setAttribute('fill', ac);
c.setAttribute('stroke', '#fff');
c.setAttribute('stroke-width', '2');
c.classList.add('pv-dot');
svg.appendChild(c);
});
rect.addEventListener('mousemove', function(e){
if (!tip) return;
var b = wrap.getBoundingClientRect();
var tx = e.clientX - b.left + 14;
var ty = e.clientY - b.top - 48;
if (tx + 140 > b.width) tx = e.clientX - b.left - 155;
tip.style.left = tx + 'px';
tip.style.top = Math.max(0, ty) + 'px';
});
rect.addEventListener('mouseleave', function(){
if (tip) tip.style.opacity = '0';
var dot = svg.querySelector('.pv-dot');
if (dot) dot.remove();
});
svg.appendChild(rect);
});
var maxLabels = 7;
var step = Math.max(1, Math.ceil(coords.length / maxLabels));
var labelIndexes = [];
for (var li = 0; li < coords.length; li += step) labelIndexes.push(li);
var last = coords.length - 1;
var prevLabel = labelIndexes[labelIndexes.length - 1];
if (prevLabel !== last && (last - prevLabel) >= Math.ceil(step * 0.6)) {
labelIndexes.push(last);
}
var labelSet = {};
labelIndexes.forEach(function(idx){ labelSet[idx] = true; });
coords.forEach(function(pt, i){
if (!labelSet[i]) return;
var t = document.createElementNS(ns, 'text');
t.setAttribute('x', pt.x);
t.setAttribute('y', H + 14);
t.setAttribute('text-anchor', 'middle');
t.setAttribute('font-size', '10');
t.setAttribute('fill', 'rgba(0,0,0,0.3)');
t.setAttribute('font-family', 'inherit');
t.textContent = pt.d.indexOf(':') !== -1 ? pt.d : pt.d.slice(5);
svg.appendChild(t);
});
wrap.appendChild(svg);
}
buildChart(series.slice(-activeDays));
document.querySelectorAll('[data-days]').forEach(function(btn){
btn.addEventListener('click', function(){
document.querySelectorAll('[data-days]').forEach(function(b){ b.classList.remove('on'); });
btn.classList.add('on');
var daysVal = btn.dataset.days;
activeDays = parseInt(daysVal, 10) || 30;
if (daysVal === '1') {
if (!ajaxUrl) return;
fetch(ajaxUrl + '?action=pulseviews_dashboard_data&type=series&days=1&_wpnonce=' + nonce)
.then(function(r){ return r.json(); })
.then(function(d){ if (d.series && d.series.length >= 2){ buildChart(d.series); } })
.catch(function(){});
return;
}
if (daysVal === 'month') {
if (!ajaxUrl) return;
fetch(ajaxUrl + '?action=pulseviews_dashboard_data&type=series&days=month&_wpnonce=' + nonce)
.then(function(r){ return r.json(); })
.then(function(d){ if (d.series){ buildChart(d.series); } })
.catch(function(){});
return;
}
if (series.length >= activeDays){
buildChart(series.slice(-activeDays));
return;
}
if (!ajaxUrl) return;
fetch(ajaxUrl + '?action=pulseviews_dashboard_data&type=series&days=' + activeDays + '&_wpnonce=' + nonce)
.then(function(r){ return r.json(); })
.then(function(d){ if (d.series){ series = d.series; buildChart(d.series); } })
.catch(function(){});
});
});
document.querySelectorAll('[data-period]').forEach(function(btn){
btn.addEventListener('click', function(){
document.querySelectorAll('[data-period]').forEach(function(b){ b.classList.remove('on'); });
btn.classList.add('on');
var list = document.getElementById('pv-posts');
if (!list) return;
list.style.opacity = '0.4';
fetch(ajaxUrl + '?action=pulseviews_dashboard_data&type=top_posts&period=' + btn.dataset.period + '&_wpnonce=' + nonce)
.then(function(r){ return r.json(); })
.then(function(d){
if (d.html) list.innerHTML = d.html;
list.style.opacity = '1';
animBars();
})
.catch(function(){ list.style.opacity = '1'; });
});
});
function animBars(){
setTimeout(function(){
document.querySelectorAll('.pv-bar-fill').forEach(function(b){
b.style.width = (b.dataset.w || '0') + '%';
});
}, 50);
}
animBars();
var resizeTimer;
window.addEventListener('resize', function(){
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function(){ buildChart(series.slice(-activeDays)); }, 150);
});
}
if (document.readyState === 'loading'){
document.addEventListener('DOMContentLoaded', pvInit);
} else {
requestAnimationFrame(function(){ setTimeout(pvInit, 0); });
}
})();
PVJS;
}
private function render_pro_sections(): void {
$is_pro = (bool) apply_filters( 'pulseviews/is_pro', false );
$reports_url = admin_url( 'admin.php?page=pulseviews-reports' );
?>
PulseViews Pro
📊
Detailed Reports
Custom date ranges
View detailed stats about the popularity of your content for any period.
Open Reports →
📅
Date Range Reports
Any period you need
Generate reports for any custom date range — 7 days, 90 days, or a full year.
View by Month →
Export, download and share your website views data in CSV or XML format.
Go to Export →
📊
Detailed Reports
Any date range
PRO
Detailed Reports
View detailed stats about the popularity of your content
Upgrade →
📅
Custom Date Range
Any period you need
PRO
Custom Date Reports
Generate reports in any date range you need
Upgrade →
PRO
Export Data
Export, download and share your views data as CSV or XML
Upgrade →
⚡
Unlock PulseViews Pro
- View detailed stats about the popularity of your content
- Generate views reports in any date range you need
- Export, download and share your website views data
⚡ Get PulseViews Pro
📊
'
. '' . esc_html__( 'No views recorded yet', 'pulseviews' ) . '
'
. '' . esc_html__( 'Views will appear here once the tracker starts recording pageviews.', 'pulseviews' ) . '
';
return;
}
echo ''
. '| ' . esc_html__( 'TITLE', 'pulseviews' ) . ' | '
. '' . esc_html__( 'TYPE', 'pulseviews' ) . ' | '
. '' . esc_html__( 'VIEWS', 'pulseviews' ) . ' | '
. '' . esc_html__( 'SHARE', 'pulseviews' ) . ' | '
. '
';
$total = max( array_sum( array_column( $posts, 'views' ) ), 1 );
foreach ( $posts as $p ) {
$share = round( $p['views'] / $total * 100, 1 );
printf(
'| %s | '
. '%s | '
. '%s | '
. '%s%% |
',
esc_url( $p['edit_link'] ),
esc_html( $p['title'] ),
esc_html( ucfirst( $p['type'] ) ),
esc_html( number_format_i18n( $p['views'] ) ),
esc_html( $share )
);
}
echo '
';
}
private function render_posts_list( array $posts, int $max_views ): void {
if ( empty( $posts ) ) {
echo '📊
'
. '
No views recorded yet
'
. '
Views will appear here once the tracker starts recording pageviews.
';
return;
}
foreach ( $posts as $i => $p ) {
$pct = $max_views > 0 ? (int) round( ( $p['views'] / $max_views ) * 100 ) : 0;
printf(
''
. '
%d'
. '
'
. '
'
. '
%s'
. '
',
(int) ( $i + 1 ),
esc_url( $p['edit_link'] ),
esc_html( $p['title'] ),
esc_html( $p['type'] ),
(int) $pct,
esc_html( $this->fmt( $p['views'] ) )
);
}
}
private function get_hourly_series(): array {
global $wpdb;
$views_table = $wpdb->prefix . 'pulseviews_views';
$today = current_time( 'Y-m-d' );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT HOUR(viewed_at) AS hour, COUNT(*) AS views
FROM %i
WHERE is_bot = 0
AND DATE(viewed_at) = %s
GROUP BY HOUR(viewed_at)
ORDER BY hour ASC",
$views_table,
$today
),
ARRAY_A
) ?? [];
$indexed = [];
foreach ( (array) $rows as $row ) {
$indexed[ (int) $row['hour'] ] = (int) $row['views'];
}
$series = [];
for ( $h = 0; $h < 24; $h++ ) {
$series[] = [
'date' => gmdate( 'H:00', mktime( $h, 0, 0 ) ),
'views' => $indexed[ $h ] ?? 0,
];
}
return $series;
}
private function get_calendar_month_series(): array {
global $wpdb;
$today = current_time( 'Y-m-d' );
$month_start = current_time( 'Y-m' ) . '-01';
$day_of_month = (int) current_time( 'j' );
$agg_table = $wpdb->prefix . 'pulseviews_aggregates';
$views_table = $wpdb->prefix . 'pulseviews_views';
$agg_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT period_date AS date, SUM(view_count) AS views
FROM %i
WHERE period_type='day' AND period_date >= %s AND period_date < %s
GROUP BY period_date ORDER BY period_date ASC",
$agg_table,
$month_start,
$today
),
ARRAY_A
) ?? [];
if ( empty( $agg_rows ) ) {
$agg_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT DATE(viewed_at) AS date, COUNT(*) AS views
FROM %i
WHERE is_bot=0 AND DATE(viewed_at) >= %s AND DATE(viewed_at) < %s
GROUP BY DATE(viewed_at) ORDER BY date ASC",
$views_table,
$month_start,
$today
),
ARRAY_A
) ?? [];
}
$today_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM %i WHERE is_bot=0 AND DATE(viewed_at) = %s",
$views_table,
$today
)
);
$indexed = array_column( (array) $agg_rows, 'views', 'date' );
$indexed[$today] = $today_count;
$series = [];
for ( $i = 0; $i < $day_of_month; $i++ ) {
$d = gmdate( 'Y-m-d', strtotime( $month_start . " +{$i} days" ) );
$series[] = [ 'date' => $d, 'views' => (int) ( $indexed[ $d ] ?? 0 ) ];
}
return $series;
}
private function get_site_series( int $days ): array {
global $wpdb;
$today = current_time( 'Y-m-d' );
$start = gmdate( 'Y-m-d', strtotime( "-{$days} days", current_time( 'timestamp' ) ) );
$agg_table = $wpdb->prefix . 'pulseviews_aggregates';
$views_table = $wpdb->prefix . 'pulseviews_views';
$agg_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT period_date AS date, SUM(view_count) AS views
FROM %i
WHERE period_type='day' AND period_date >= %s AND period_date < %s
GROUP BY period_date ORDER BY period_date ASC",
$agg_table,
$start,
$today
),
ARRAY_A
) ?? [];
if ( empty( $agg_rows ) ) {
$agg_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT DATE(viewed_at) AS date, COUNT(*) AS views
FROM %i
WHERE is_bot=0 AND DATE(viewed_at) >= %s AND DATE(viewed_at) < %s
GROUP BY DATE(viewed_at) ORDER BY date ASC",
$views_table,
$start,
$today
),
ARRAY_A
) ?? [];
}
$today_count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM %i WHERE is_bot=0 AND DATE(viewed_at) = %s",
$views_table,
$today
)
);
$indexed = array_column( (array) $agg_rows, 'views', 'date' );
$indexed[ $today ] = $today_count;
$series = [];
for ( $i = $days - 1; $i >= 0; $i-- ) {
$d = gmdate( 'Y-m-d', strtotime( "-{$i} days", current_time( 'timestamp' ) ) );
$series[] = [ 'date' => $d, 'views' => (int) ( $indexed[ $d ] ?? 0 ) ];
}
return $series;
}
private function fmt( int $n ): string {
if ( $n >= 1_000_000 ) {
return number_format_i18n( $n / 1_000_000, 1 ) . 'M';
}
if ( $n >= 1_000 ) {
return number_format_i18n( $n / 1_000, 1 ) . 'K';
}
return number_format_i18n( $n );
}
public function register_dashboard_widget(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
wp_add_dashboard_widget(
'pulseviews_dashboard_widget',
__( 'PulseViews — Analytics', 'pulseviews' ),
[ $this, 'render_dashboard_widget' ]
);
}
public function render_dashboard_widget(): void {
$repo = new \PulseViews\Database\ViewsRepository();
$totals = $repo->get_site_totals();
$top = $repo->get_top_posts( 5, '', 'total' );
$top_posts = array_map( static function ( array $item ): array {
$post = get_post( $item['post_id'] );
return [
'views' => $item['views'],
'title' => $post ? get_the_title( $post ) : "Post #{$item['post_id']}",
'link' => $post ? ( get_edit_post_link( $post, 'raw' ) ?: '' ) : '',
];
}, $top );
$max = ! empty( $top_posts ) ? max( array_column( $top_posts, 'views' ) ) : 1;
$dashboard_url = admin_url( 'admin.php?page=pulseviews' );
?>
get_dashboard_css() );
} else {
wp_add_inline_style( 'pulseviews-admin', $settings_obj->get_settings_css() );
}
wp_enqueue_script(
'pulseviews-admin-js',
PULSEVIEWS_URL . 'assets/admin/loader.js',
[],
PULSEVIEWS_VERSION,
true
);
if ( str_contains( $hook, 'toplevel_page_pulseviews' ) ) {
$series = $this->get_calendar_month_series();
wp_localize_script( 'pulseviews-admin-js', 'pulseviewsDashData', [
'series' => $series,
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'pulseviews_dashboard' ),
] );
wp_add_inline_script( 'pulseviews-admin-js', $this->get_dashboard_js() );
} else {
wp_add_inline_script( 'pulseviews-admin-js', $settings_obj->get_settings_js() );
}
}
public function add_views_column( array $columns ): array {
$columns['pulseviews_views'] = __( 'Views', 'pulseviews' );
return $columns;
}
public function render_views_column( string $column, int $post_id ): void {
if ( 'pulseviews_views' !== $column ) {
return;
}
global $wpdb;
$table = $wpdb->prefix . 'pulseviews_post_totals';
$views = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT total_views FROM %i WHERE post_id=%d', $table, $post_id ) );
echo '' . esc_html( number_format_i18n( $views ) ) . '';
}
public function make_views_sortable( array $cols ): array {
$cols['pulseviews_views'] = 'pulseviews_views';
return $cols;
}
public function sort_by_views( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( 'pulseviews_views' !== $query->get( 'orderby' ) ) {
return;
}
global $wpdb;
$t = $wpdb->prefix . 'pulseviews_post_totals';
add_filter( 'posts_join', static fn( $j ) => $j . " LEFT JOIN `{$t}` pvt ON pvt.post_id={$wpdb->posts}.ID " );
add_filter( 'posts_orderby', static function ( $ob, $q ) use ( $query ) {
if ( $q !== $query ) {
return $ob;
}
return 'COALESCE(pvt.total_views,0) ' . ( strtoupper( $q->get( 'order' ) ) === 'ASC' ? 'ASC' : 'DESC' );
}, 10, 2 );
}
public function handle_activation_redirect(): void {
if ( ! get_transient( 'pulseviews_activation_redirect' ) ) {
return;
}
delete_transient( 'pulseviews_activation_redirect' );
if ( ! is_multisite() ) {
wp_safe_redirect( admin_url( 'admin.php?page=pulseviews' ) );
exit;
}
}
}