Ansico-Stat-plugin/ansico-stat-plugin/ansico-stat-plugin.php

5639 lines
298 KiB
PHP
Raw Permalink Normal View History

2026-04-15 19:53:03 +00:00
<?php
/**
* Plugin Name: Ansico Stat Plugin
* Plugin URI: https://ansico.dk/Ansico/Ansico-Stat-plugin
* Description: Simple WP plugin with basic website statistics.
2026-04-18 21:38:56 +00:00
* Version: 1.1.1
2026-04-15 19:53:03 +00:00
* Author: Andreas Andersen (Ansico)
* Author URI: https://ansico.dk
* Text Domain: ansico-stat-plugin
* Requires at least: 6.0
* Requires PHP: 7.4
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
*/
if (!defined('ABSPATH')) {
exit;
}
if (!class_exists('Ansico_Stat_Plugin')) {
class Ansico_Stat_Plugin {
2026-04-18 21:38:56 +00:00
const VERSION = '1.1.1';
2026-04-15 19:53:03 +00:00
const OPTION_KEY = 'ansico_stat_plugin_settings';
const INSTALL_OPTION_KEY = 'ansico_stat_plugin_install_date';
const TOTAL_META_KEY = 'post_views_count';
const COOKIE_PREFIX = 'ansico_stat_seen_';
const COOKIE_TTL = 1800; // 30 minutes
const SCHEMA_VERSION_OPTION_KEY = 'ansico_stat_plugin_schema_version';
const MENU_SLUG_SETTINGS = 'ansico-stat-plugin-settings';
const MENU_SLUG_STATS = 'ansico-stat-plugin';
const MENU_SLUG_YEARLY = 'ansico-stat-plugin-yearly';
2026-04-17 21:56:44 +00:00
const MENU_SLUG_SINGLE = 'ansico-stat-plugin-single';
2026-04-18 21:38:56 +00:00
const MENU_SLUG_LIFETIME = 'ansico-stat-plugin-lifetime';
2026-04-15 19:53:03 +00:00
const COOKIE_TTL_MESSAGE = 1800;
2026-04-18 21:38:56 +00:00
const VISITOR_COOKIE_NAME = 'ansico_stat_visitor_id';
const VISITOR_COOKIE_TTL = 34560000; // 400 days
const SESSION_COOKIE_NAME = 'ansico_stat_session';
2026-04-15 19:53:03 +00:00
public function __construct() {
register_activation_hook(__FILE__, [__CLASS__, 'activate']);
register_deactivation_hook(__FILE__, [__CLASS__, 'deactivate']);
add_action('init', [$this, 'load_textdomain']);
add_action('init', [$this, 'maybe_upgrade_schema'], 5);
add_action('template_redirect', [$this, 'maybe_track_view']);
add_filter('the_content', [$this, 'append_admin_view_count_to_content'], 999);
add_action('wp_footer', [$this, 'render_admin_view_count_fallback'], 99);
add_action('admin_menu', [$this, 'register_admin_page']);
2026-04-17 21:56:44 +00:00
add_action('admin_bar_menu', [$this, 'register_frontend_admin_bar_link'], 90);
2026-04-15 19:53:03 +00:00
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
add_action('wp_dashboard_setup', [$this, 'register_dashboard_widget']);
add_action('admin_post_ansico_stat_export_csv', [$this, 'handle_export_csv']);
2026-04-17 21:56:44 +00:00
add_action('admin_post_ansico_stat_export_single_csv', [$this, 'handle_export_single_csv']);
2026-04-15 19:53:03 +00:00
add_action('admin_post_ansico_stat_reset_all', [$this, 'handle_reset_all_views']);
add_action('admin_post_ansico_stat_save_settings', [$this, 'handle_save_settings']);
2026-04-18 21:38:56 +00:00
add_action('admin_post_ansico_stat_google_connect', [$this, 'handle_google_connect']);
add_action('admin_post_ansico_stat_google_disconnect', [$this, 'handle_google_disconnect']);
add_action('admin_post_ansico_stat_google_oauth_callback', [$this, 'handle_google_oauth_callback']);
add_action('admin_post_ansico_stat_google_generate_verification_token', [$this, 'handle_google_generate_verification_token']);
add_action('admin_post_ansico_stat_google_verify_site', [$this, 'handle_google_verify_site']);
add_action('admin_post_ansico_stat_google_sync_month', [$this, 'handle_google_sync_month']);
add_action('wp_head', [$this, 'render_google_site_verification_meta_tag'], 1);
2026-04-15 19:53:03 +00:00
add_action('admin_init', [$this, 'register_admin_columns']);
add_action('restrict_manage_posts', [$this, 'render_admin_view_filter']);
add_action('pre_get_posts', [$this, 'handle_admin_list_sorting']);
add_filter('posts_clauses', [$this, 'add_monthly_views_sorting_clauses'], 10, 2);
}
public static function activate() {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->get_charset_collate();
$daily_table = self::daily_table_name();
$post_daily_table = self::post_daily_table_name();
$page_daily_table = self::page_daily_table_name();
$referral_table = self::referral_table_name();
$dimension_table = self::dimension_table_name();
2026-04-18 21:38:56 +00:00
$landing_table = self::landing_table_name();
$unique_total_table = self::unique_total_table_name();
$unique_landing_table = self::unique_landing_table_name();
$unique_page_table = self::unique_page_table_name();
$unique_referral_table = self::unique_referral_table_name();
$unique_dimension_table = self::unique_dimension_table_name();
$search_query_table = self::search_query_table_name();
2026-04-15 19:53:03 +00:00
$sql_daily = "CREATE TABLE {$daily_table} (
stat_date date NOT NULL,
views bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (stat_date)
) {$charset_collate};";
$sql_post_daily = "CREATE TABLE {$post_daily_table} (
post_id bigint(20) unsigned NOT NULL,
stat_date date NOT NULL,
views bigint(20) unsigned NOT NULL DEFAULT 0,
2026-04-17 21:56:44 +00:00
unique_visitors bigint(20) unsigned NOT NULL DEFAULT 0,
2026-04-15 19:53:03 +00:00
PRIMARY KEY (post_id, stat_date),
KEY stat_date (stat_date),
KEY views (views)
) {$charset_collate};";
$sql_page_daily = "CREATE TABLE {$page_daily_table} (
page_key varchar(191) NOT NULL,
page_type varchar(32) NOT NULL,
object_id bigint(20) unsigned NULL DEFAULT NULL,
page_label varchar(255) NOT NULL,
page_url text NULL,
stat_date date NOT NULL,
views bigint(20) unsigned NOT NULL DEFAULT 0,
2026-04-17 21:56:44 +00:00
unique_visitors bigint(20) unsigned NOT NULL DEFAULT 0,
2026-04-15 19:53:03 +00:00
PRIMARY KEY (page_key, stat_date),
KEY stat_date (stat_date),
KEY page_type (page_type),
KEY object_id (object_id),
KEY views (views)
) {$charset_collate};";
$sql_referral = "CREATE TABLE {$referral_table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
stat_date date NOT NULL,
category varchar(32) NOT NULL,
source_url text NULL,
source_host varchar(191) NULL,
2026-04-18 21:38:56 +00:00
landing_page_key varchar(191) NULL,
landing_page_label varchar(255) NULL,
landing_page_url text NULL,
2026-04-15 19:53:03 +00:00
visits bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
2026-04-18 21:38:56 +00:00
UNIQUE KEY unique_referral (stat_date, category, source_host(191), source_url(191), landing_page_key),
2026-04-15 19:53:03 +00:00
KEY stat_date (stat_date),
KEY category (category),
2026-04-18 21:38:56 +00:00
KEY source_host (source_host),
KEY landing_page_key (landing_page_key(191))
2026-04-15 19:53:03 +00:00
) {$charset_collate};";
$sql_dimensions = "CREATE TABLE {$dimension_table} (
stat_date date NOT NULL,
dimension_type varchar(32) NOT NULL,
dimension_value varchar(191) NOT NULL,
views bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (stat_date, dimension_type, dimension_value),
KEY dimension_type (dimension_type),
KEY views (views)
) {$charset_collate};";
2026-04-18 21:38:56 +00:00
$sql_landing = "CREATE TABLE {$landing_table} (
stat_date date NOT NULL,
page_key varchar(191) NOT NULL,
page_type varchar(32) NOT NULL,
object_id bigint(20) unsigned NULL DEFAULT NULL,
page_label varchar(255) NOT NULL,
page_url text NULL,
visits bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (stat_date, page_key),
KEY stat_date (stat_date),
KEY page_type (page_type),
KEY object_id (object_id),
KEY visits (visits)
) {$charset_collate};";
$sql_unique_total = "CREATE TABLE {$unique_total_table} (
period_type varchar(10) NOT NULL,
period_key varchar(16) NOT NULL,
visitor_hash varchar(64) NOT NULL,
first_seen datetime NOT NULL,
PRIMARY KEY (period_type, period_key, visitor_hash),
KEY period_lookup (period_type, period_key)
) {$charset_collate};";
$sql_unique_page = "CREATE TABLE {$unique_page_table} (
period_type varchar(10) NOT NULL,
period_key varchar(16) NOT NULL,
page_key varchar(191) NOT NULL,
visitor_hash varchar(64) NOT NULL,
first_seen datetime NOT NULL,
PRIMARY KEY (period_type, period_key, page_key, visitor_hash),
KEY period_page_lookup (period_type, period_key, page_key)
) {$charset_collate};";
$sql_unique_landing = "CREATE TABLE {$unique_landing_table} (
period_type varchar(10) NOT NULL,
period_key varchar(16) NOT NULL,
page_key varchar(191) NOT NULL,
visitor_hash varchar(64) NOT NULL,
first_seen datetime NOT NULL,
PRIMARY KEY (period_type, period_key, page_key, visitor_hash),
KEY period_landing_lookup (period_type, period_key, page_key)
) {$charset_collate};";
$sql_unique_referral = "CREATE TABLE {$unique_referral_table} (
period_type varchar(10) NOT NULL,
period_key varchar(16) NOT NULL,
category varchar(32) NOT NULL,
source_host varchar(191) NOT NULL DEFAULT '',
source_url varchar(191) NOT NULL DEFAULT '',
landing_page_key varchar(191) NOT NULL DEFAULT '',
visitor_hash varchar(64) NOT NULL,
first_seen datetime NOT NULL,
PRIMARY KEY (period_type, period_key, category, source_host, source_url, landing_page_key, visitor_hash),
KEY period_referral_lookup (period_type, period_key, category)
) {$charset_collate};";
$sql_unique_dimension = "CREATE TABLE {$unique_dimension_table} (
period_type varchar(10) NOT NULL,
period_key varchar(16) NOT NULL,
dimension_type varchar(32) NOT NULL,
dimension_value varchar(191) NOT NULL,
visitor_hash varchar(64) NOT NULL,
first_seen datetime NOT NULL,
PRIMARY KEY (period_type, period_key, dimension_type, dimension_value, visitor_hash),
KEY period_dimension_lookup (period_type, period_key, dimension_type, dimension_value)
) {$charset_collate};";
$sql_search_queries = "CREATE TABLE {$search_query_table} (
month_key char(7) NOT NULL,
property_url varchar(255) NOT NULL,
query_text varchar(255) NOT NULL,
clicks bigint(20) unsigned NOT NULL DEFAULT 0,
impressions bigint(20) unsigned NOT NULL DEFAULT 0,
ctr decimal(8,6) NOT NULL DEFAULT 0,
position decimal(10,4) NOT NULL DEFAULT 0,
updated_at datetime NOT NULL,
PRIMARY KEY (month_key, property_url, query_text),
KEY property_month_lookup (property_url, month_key),
KEY clicks (clicks),
KEY impressions (impressions)
) {$charset_collate};";
2026-04-15 19:53:03 +00:00
dbDelta($sql_daily);
dbDelta($sql_post_daily);
dbDelta($sql_page_daily);
dbDelta($sql_referral);
dbDelta($sql_dimensions);
2026-04-18 21:38:56 +00:00
dbDelta($sql_landing);
dbDelta($sql_unique_total);
dbDelta($sql_unique_page);
dbDelta($sql_unique_landing);
dbDelta($sql_unique_referral);
dbDelta($sql_unique_dimension);
dbDelta($sql_search_queries);
self::ensure_referral_table_schema($referral_table, $charset_collate);
2026-04-15 19:53:03 +00:00
if (!get_option(self::INSTALL_OPTION_KEY)) {
update_option(self::INSTALL_OPTION_KEY, current_time('Y-m-d'), false);
}
update_option(self::SCHEMA_VERSION_OPTION_KEY, self::VERSION, false);
}
public static function deactivate() {
// Keep data on deactivation.
}
2026-04-18 21:38:56 +00:00
protected static function ensure_referral_table_schema($referral_table, $charset_collate) {
global $wpdb;
$columns = $wpdb->get_col("SHOW COLUMNS FROM {$referral_table}", 0);
if (!is_array($columns)) {
return;
}
if (!in_array('landing_page_key', $columns, true)) {
$wpdb->query("ALTER TABLE {$referral_table} ADD COLUMN landing_page_key varchar(191) NULL AFTER source_host");
}
if (!in_array('landing_page_label', $columns, true)) {
$wpdb->query("ALTER TABLE {$referral_table} ADD COLUMN landing_page_label varchar(255) NULL AFTER landing_page_key");
}
if (!in_array('landing_page_url', $columns, true)) {
$wpdb->query("ALTER TABLE {$referral_table} ADD COLUMN landing_page_url text NULL AFTER landing_page_label");
}
$indexes = $wpdb->get_results("SHOW INDEX FROM {$referral_table}", ARRAY_A);
$has_landing_index = false;
$has_unique_referral = false;
if (is_array($indexes)) {
foreach ($indexes as $index) {
$key_name = (string) ($index['Key_name'] ?? '');
$column_name = (string) ($index['Column_name'] ?? '');
if ($key_name === 'unique_referral') {
$has_unique_referral = true;
if ($column_name === 'landing_page_key') {
$has_landing_index = true;
}
}
}
}
if ($has_unique_referral && !$has_landing_index) {
$wpdb->query("ALTER TABLE {$referral_table} DROP INDEX unique_referral");
$has_unique_referral = false;
}
if (!$has_unique_referral) {
$wpdb->query("ALTER TABLE {$referral_table} ADD UNIQUE KEY unique_referral (stat_date, category, source_host(191), source_url(191), landing_page_key)");
}
$landing_key_index_exists = false;
if (is_array($indexes)) {
foreach ($indexes as $index) {
if ((string) ($index['Key_name'] ?? '') === 'landing_page_key') {
$landing_key_index_exists = true;
break;
}
}
}
if (!$landing_key_index_exists) {
$wpdb->query("ALTER TABLE {$referral_table} ADD KEY landing_page_key (landing_page_key(191))");
}
}
2026-04-15 19:53:03 +00:00
public function maybe_upgrade_schema() {
$stored_version = (string) get_option(self::SCHEMA_VERSION_OPTION_KEY, '');
if ($stored_version === self::VERSION) {
return;
}
self::activate();
}
public function load_textdomain() {
load_plugin_textdomain('ansico-stat-plugin', false, dirname(plugin_basename(__FILE__)) . '/languages');
}
public static function daily_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_daily';
}
public static function post_daily_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_post_daily';
}
public static function page_daily_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_page_daily';
}
public static function referral_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_referrals';
}
public static function dimension_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_dimensions';
}
2026-04-18 21:38:56 +00:00
public static function landing_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_landing_daily';
}
public static function unique_total_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_unique_total';
}
public static function unique_page_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_unique_page';
}
public static function unique_landing_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_unique_landing';
}
public static function unique_referral_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_unique_referral';
}
public static function unique_dimension_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_unique_dimension';
}
public static function search_query_table_name() {
global $wpdb;
return $wpdb->prefix . 'ansico_stat_search_queries';
}
2026-04-15 19:53:03 +00:00
protected function get_default_track_post_types() {
$post_types = get_post_types(['public' => true], 'names');
$post_types = array_filter($post_types, function($post_type) {
return !in_array($post_type, ['attachment', 'revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles'], true);
});
return array_values($post_types);
}
protected function get_settings() {
$settings = get_option(self::OPTION_KEY, []);
if (!is_array($settings)) {
$settings = [];
}
$settings = wp_parse_args($settings, [
2026-04-18 21:38:56 +00:00
'visitor_counting_mode' => 'count_all',
'exclude_known_bots' => 1,
'top_list_rows' => 10,
'referral_rows' => 0,
'revisit_minutes' => 30,
'frontend_label' => 'Views',
'track_post_types' => $this->get_default_track_post_types(),
'track_front_page' => 1,
'track_posts_page' => 1,
'track_archives' => 1,
'track_search' => 1,
'track_404' => 1,
'google_client_id' => '',
'google_client_secret' => '',
'google_property' => '',
'google_access_token' => '',
'google_refresh_token' => '',
'google_token_expires_at' => 0,
'google_connected_email' => '',
'google_site_verification_token' => '',
'google_site_verification_method' => 'META',
'google_last_sync_month' => '',
'ipinfo_token' => '',
2026-04-15 19:53:03 +00:00
]);
if (!is_array($settings['track_post_types'])) {
$settings['track_post_types'] = $this->get_default_track_post_types();
}
$settings['track_post_types'] = array_values(array_filter(array_map('sanitize_key', $settings['track_post_types'])));
$settings['visitor_counting_mode'] = in_array($settings['visitor_counting_mode'], ['count_all', 'exclude_admins', 'exclude_all_logged_in'], true) ? $settings['visitor_counting_mode'] : 'count_all';
$settings['revisit_minutes'] = max(1, min(10080, (int) $settings['revisit_minutes']));
$settings['frontend_label'] = sanitize_text_field((string) $settings['frontend_label']);
if ($settings['frontend_label'] === '') {
$settings['frontend_label'] = 'Views';
}
2026-04-18 21:38:56 +00:00
$settings['google_client_id'] = sanitize_text_field((string) $settings['google_client_id']);
$settings['google_client_secret'] = sanitize_text_field((string) $settings['google_client_secret']);
$settings['google_property'] = sanitize_text_field((string) $settings['google_property']);
$settings['google_access_token'] = sanitize_text_field((string) $settings['google_access_token']);
$settings['google_refresh_token'] = sanitize_text_field((string) $settings['google_refresh_token']);
$settings['google_token_expires_at'] = max(0, (int) $settings['google_token_expires_at']);
$settings['google_connected_email'] = sanitize_email((string) $settings['google_connected_email']);
$settings['google_site_verification_token'] = sanitize_text_field((string) $settings['google_site_verification_token']);
$settings['google_site_verification_method'] = strtoupper(sanitize_key((string) $settings['google_site_verification_method']));
if (!in_array($settings['google_site_verification_method'], ['META', 'DNS_TXT'], true)) {
$settings['google_site_verification_method'] = 'META';
}
$settings['google_last_sync_month'] = sanitize_text_field((string) $settings['google_last_sync_month']);
$settings['ipinfo_token'] = sanitize_text_field((string) $settings['ipinfo_token']);
2026-04-15 19:53:03 +00:00
return $settings;
}
protected function get_cookie_ttl() {
$settings = $this->get_settings();
return max(60, (int) $settings['revisit_minutes'] * 60);
}
protected function should_exclude_current_user() {
$settings = $this->get_settings();
$mode = $settings['visitor_counting_mode'] ?? 'count_all';
if ($mode === 'exclude_all_logged_in' && is_user_logged_in()) {
return true;
}
if ($mode === 'exclude_admins' && current_user_can('manage_options')) {
return true;
}
return false;
}
protected function is_tracking_enabled_for_context(array $page_context) {
$settings = $this->get_settings();
$page_type = (string) ($page_context['page_type'] ?? '');
if (($page_context['kind'] ?? '') === 'singular') {
$post_type = sanitize_key((string) ($page_context['post_type'] ?? get_post_type((int) ($page_context['post_id'] ?? 0))));
return in_array($post_type, $settings['track_post_types'], true);
}
if ($page_type === 'front_page') {
return !empty($settings['track_front_page']);
}
if ($page_type === 'home') {
return !empty($settings['track_posts_page']);
}
if ($page_type === 'search') {
return !empty($settings['track_search']);
}
if ($page_type === '404') {
return !empty($settings['track_404']);
}
return !empty($settings['track_archives']);
}
protected function get_frontend_icon_markup() {
return '<span class="ansico-stat-frontend-icon" aria-hidden="true" style="display:inline-flex;vertical-align:middle;margin-right:.45rem;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="11" width="3" height="9" rx="1" fill="currentColor"/><rect x="10.5" y="7" width="3" height="13" rx="1" fill="currentColor"/><rect x="18" y="3" width="3" height="17" rx="1" fill="currentColor"/></svg></span>';
}
2026-04-18 21:38:56 +00:00
protected function get_client_ip_address() {
$keys = [
'HTTP_CF_CONNECTING_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_CLIENT_IP',
'REMOTE_ADDR',
];
foreach ($keys as $key) {
if (empty($_SERVER[$key])) {
continue;
}
$raw_value = sanitize_text_field((string) wp_unslash($_SERVER[$key]));
$parts = array_map('trim', explode(',', $raw_value));
foreach ($parts as $part) {
$ip = filter_var($part, FILTER_VALIDATE_IP);
if ($ip) {
return $ip;
}
}
}
return '';
}
protected function lookup_country_code_via_ipinfo(string $ip_address) {
$settings = $this->get_settings();
$token = trim((string) ($settings['ipinfo_token'] ?? ''));
if ($token === '' || $ip_address === '' || !filter_var($ip_address, FILTER_VALIDATE_IP)) {
return '';
}
if (filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return '';
}
$cache_key = 'ansico_country_' . md5($ip_address);
$cached = get_transient($cache_key);
if (is_string($cached) && preg_match('/^[A-Z]{2}$/', $cached)) {
return $cached;
}
$response = wp_remote_get(
'https://api.ipinfo.io/lite/' . rawurlencode($ip_address) . '?token=' . rawurlencode($token),
[
'timeout' => 5,
'user-agent' => 'Ansico Stat Plugin/' . self::VERSION . '; ' . home_url('/'),
'headers' => [
'Accept' => 'application/json',
],
]
);
if (is_wp_error($response)) {
return '';
}
$code = (int) wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 300) {
return '';
}
$data = json_decode((string) wp_remote_retrieve_body($response), true);
if (!is_array($data)) {
return '';
}
$country_code = strtoupper(sanitize_text_field((string) ($data['country_code'] ?? '')));
if (!preg_match('/^[A-Z]{2}$/', $country_code)) {
return '';
}
set_transient($cache_key, $country_code, DAY_IN_SECONDS * 30);
return $country_code;
}
2026-04-15 19:53:03 +00:00
protected function get_country_code() {
$candidates = [
$_SERVER['HTTP_CF_IPCOUNTRY'] ?? '',
$_SERVER['GEOIP_COUNTRY_CODE'] ?? '',
$_SERVER['HTTP_X_COUNTRY_CODE'] ?? '',
$_SERVER['HTTP_X_APPENGINE_COUNTRY'] ?? '',
];
foreach ($candidates as $candidate) {
$code = strtoupper(sanitize_text_field((string) $candidate));
if (preg_match('/^[A-Z]{2}$/', $code)) {
return $code;
}
}
2026-04-18 21:38:56 +00:00
$ipinfo_code = $this->lookup_country_code_via_ipinfo($this->get_client_ip_address());
if ($ipinfo_code !== '') {
return $ipinfo_code;
}
2026-04-15 19:53:03 +00:00
return 'Unknown';
}
2026-04-18 21:38:56 +00:00
protected function get_country_flag_emoji(string $country_code) {
$country_code = strtoupper(trim($country_code));
if (!preg_match('/^[A-Z]{2}$/', $country_code)) {
return '';
}
$base = 127397;
$first = mb_chr($base + ord($country_code[0]), 'UTF-8');
$second = mb_chr($base + ord($country_code[1]), 'UTF-8');
if (!is_string($first) || !is_string($second)) {
return '';
}
return $first . $second;
}
2026-04-15 19:53:03 +00:00
protected function get_country_label(string $country_code) {
$country_code = strtoupper(trim($country_code));
if ($country_code === '' || $country_code === 'UNKNOWN') {
return __('Unknown', 'ansico-stat-plugin');
}
2026-04-18 21:38:56 +00:00
$label = '';
2026-04-15 19:53:03 +00:00
if (function_exists('locale_get_display_region')) {
$label = locale_get_display_region('-' . $country_code, get_locale());
2026-04-18 21:38:56 +00:00
if (!is_string($label)) {
$label = '';
2026-04-15 19:53:03 +00:00
}
}
2026-04-18 21:38:56 +00:00
if ($label === '') {
$label = $country_code;
}
$flag = $this->get_country_flag_emoji($country_code);
return $flag !== '' ? $flag . ' ' . $label : $label;
2026-04-15 19:53:03 +00:00
}
protected function get_device_type() {
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? strtolower((string) wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
if ($user_agent === '') {
return 'Unknown';
}
if (strpos($user_agent, 'tablet') !== false || strpos($user_agent, 'ipad') !== false || strpos($user_agent, 'kindle') !== false || strpos($user_agent, 'playbook') !== false) {
return 'Tablet';
}
if (strpos($user_agent, 'mobile') !== false || strpos($user_agent, 'iphone') !== false || strpos($user_agent, 'android') !== false || strpos($user_agent, 'phone') !== false) {
return 'Mobile';
}
return 'Desktop';
}
protected function get_browser_name() {
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? strtolower((string) wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
$browsers = [
'edg/' => 'Edge',
'opr/' => 'Opera',
'opera' => 'Opera',
'brave' => 'Brave',
'firefox' => 'Firefox',
'samsungbrowser' => 'Samsung Internet',
'chrome' => 'Chrome',
'crios' => 'Chrome',
'safari' => 'Safari',
'trident/' => 'Internet Explorer',
'msie' => 'Internet Explorer',
];
foreach ($browsers as $needle => $label) {
if (strpos($user_agent, $needle) !== false) {
if ($label === 'Safari' && (strpos($user_agent, 'chrome') !== false || strpos($user_agent, 'crios') !== false || strpos($user_agent, 'android') !== false)) {
continue;
}
return $label;
}
}
return 'Other';
}
protected function get_os_name() {
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? strtolower((string) wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
$systems = [
'windows nt' => 'Windows',
'iphone' => 'iOS',
'ipad' => 'iPadOS',
'mac os x' => 'macOS',
'android' => 'Android',
'linux' => 'Linux',
'cros' => 'Chrome OS',
];
foreach ($systems as $needle => $label) {
if (strpos($user_agent, $needle) !== false) {
return $label;
}
}
return 'Other';
}
2026-04-18 21:38:56 +00:00
protected function get_current_dimension_values() {
2026-04-15 19:53:03 +00:00
$dimensions = [
'country' => $this->get_country_code(),
'device' => $this->get_device_type(),
'browser' => $this->get_browser_name(),
'os' => $this->get_os_name(),
];
2026-04-18 21:38:56 +00:00
if (is_search()) {
$search_query = trim((string) get_search_query());
$dimensions['internal_search'] = $search_query !== '' ? $search_query : __('(empty search)', 'ansico-stat-plugin');
}
return $dimensions;
}
protected function get_period_keys(string $today) {
return [
'day' => $today,
'month' => substr($today, 0, 7),
'year' => substr($today, 0, 4),
];
}
protected function get_or_create_visitor_hash() {
$cookie_name = self::VISITOR_COOKIE_NAME;
$existing = isset($_COOKIE[$cookie_name]) ? sanitize_text_field((string) wp_unslash($_COOKIE[$cookie_name])) : '';
if (preg_match('/^[A-Za-z0-9_-]{12,64}$/', $existing)) {
return $existing;
}
$visitor_hash = wp_generate_uuid4();
$visitor_hash = str_replace('-', '', $visitor_hash);
if (!headers_sent()) {
setcookie($cookie_name, $visitor_hash, time() + self::VISITOR_COOKIE_TTL, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
$_COOKIE[$cookie_name] = $visitor_hash;
}
return $visitor_hash;
}
protected function is_new_session_visit() {
$cookie_name = self::SESSION_COOKIE_NAME;
$existing = isset($_COOKIE[$cookie_name]) ? sanitize_text_field((string) wp_unslash($_COOKIE[$cookie_name])) : '';
$is_new = ($existing === '');
if (!headers_sent()) {
setcookie($cookie_name, '1', time() + $this->get_cookie_ttl(), COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
$_COOKIE[$cookie_name] = '1';
}
return $is_new;
}
protected function register_landing_visit(string $today, string $visitor_hash, array $page_context) {
global $wpdb;
$page_key = substr(sanitize_text_field((string) ($page_context['page_key'] ?? '')), 0, 191);
if ($page_key === '') {
return;
}
$landing_table = self::landing_table_name();
$page_type = substr(sanitize_key((string) ($page_context['page_type'] ?? 'archive')), 0, 32);
$object_id = isset($page_context['object_id']) ? (int) $page_context['object_id'] : 0;
$page_label = sanitize_text_field((string) ($page_context['label'] ?? ''));
$page_url = isset($page_context['url']) ? esc_url_raw((string) $page_context['url']) : '';
$wpdb->query($wpdb->prepare(
"INSERT INTO {$landing_table} (stat_date, page_key, page_type, object_id, page_label, page_url, visits)
VALUES (%s, %s, %s, %d, %s, %s, 1)
ON DUPLICATE KEY UPDATE visits = visits + 1, page_label = VALUES(page_label), page_url = VALUES(page_url)",
$today,
$page_key,
$page_type,
$object_id,
$page_label,
$page_url
));
if ($visitor_hash === '') {
return;
}
$unique_landing_table = self::unique_landing_table_name();
$timestamp = current_time('mysql');
foreach ($this->get_period_keys($today) as $period_type => $period_key) {
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$unique_landing_table} (period_type, period_key, page_key, visitor_hash, first_seen) VALUES (%s, %s, %s, %s, %s)",
$period_type,
$period_key,
$page_key,
$visitor_hash,
$timestamp
));
}
}
protected function register_unique_visitor(string $today, string $visitor_hash, array $page_context, array $referer_data, array $dimensions) {
global $wpdb;
if ($visitor_hash === '' || empty($page_context['page_key'])) {
return;
}
$periods = $this->get_period_keys($today);
$timestamp = current_time('mysql');
$page_key = substr(sanitize_text_field((string) $page_context['page_key']), 0, 191);
$category = substr(sanitize_key((string) ($referer_data['category'] ?? 'direct')), 0, 32);
$source_host = substr(sanitize_text_field((string) ($referer_data['source_host'] ?? '')), 0, 191);
$source_url = substr(esc_url_raw((string) ($referer_data['source_url'] ?? '')), 0, 191);
$landing_page_key = substr(sanitize_text_field((string) ($page_context['page_key'] ?? '')), 0, 191);
$unique_total_table = self::unique_total_table_name();
$unique_page_table = self::unique_page_table_name();
$unique_referral_table = self::unique_referral_table_name();
$unique_dimension_table = self::unique_dimension_table_name();
foreach ($periods as $period_type => $period_key) {
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$unique_total_table} (period_type, period_key, visitor_hash, first_seen) VALUES (%s, %s, %s, %s)",
$period_type,
$period_key,
$visitor_hash,
$timestamp
));
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$unique_page_table} (period_type, period_key, page_key, visitor_hash, first_seen) VALUES (%s, %s, %s, %s, %s)",
$period_type,
$period_key,
$page_key,
$visitor_hash,
$timestamp
));
if ($period_type === 'day' && (int) $wpdb->rows_affected > 0) {
if (($page_context['kind'] ?? '') === 'singular' && !empty($page_context['post_id'])) {
$post_daily_table = self::post_daily_table_name();
$wpdb->query($wpdb->prepare(
"UPDATE {$post_daily_table} SET unique_visitors = unique_visitors + 1 WHERE post_id = %d AND stat_date = %s",
(int) $page_context['post_id'],
$today
));
} elseif (!empty($page_context['page_key'])) {
$page_daily_table = self::page_daily_table_name();
$wpdb->query($wpdb->prepare(
"UPDATE {$page_daily_table} SET unique_visitors = unique_visitors + 1 WHERE page_key = %s AND stat_date = %s",
$page_key,
$today
));
}
}
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$unique_referral_table} (period_type, period_key, category, source_host, source_url, landing_page_key, visitor_hash, first_seen) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
$period_type,
$period_key,
$category,
$source_host,
$source_url,
$landing_page_key,
$visitor_hash,
$timestamp
));
foreach ($dimensions as $dimension_type => $dimension_value) {
$dimension_type = substr(sanitize_key((string) $dimension_type), 0, 32);
$dimension_value = substr(sanitize_text_field((string) $dimension_value), 0, 191);
if ($dimension_type === '') {
continue;
}
if ($dimension_value === '') {
$dimension_value = 'Unknown';
}
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$unique_dimension_table} (period_type, period_key, dimension_type, dimension_value, visitor_hash, first_seen) VALUES (%s, %s, %s, %s, %s, %s)",
$period_type,
$period_key,
$dimension_type,
$dimension_value,
$visitor_hash,
$timestamp
));
}
}
}
protected function increment_dimension_views(string $today) {
global $wpdb;
$table = self::dimension_table_name();
$dimensions = $this->get_current_dimension_values();
2026-04-15 19:53:03 +00:00
foreach ($dimensions as $dimension_type => $dimension_value) {
$dimension_value = substr(sanitize_text_field((string) $dimension_value), 0, 191);
if ($dimension_value === '') {
$dimension_value = 'Unknown';
}
$wpdb->query($wpdb->prepare(
"INSERT INTO {$table} (stat_date, dimension_type, dimension_value, views)
VALUES (%s, %s, %s, 1)
ON DUPLICATE KEY UPDATE views = views + 1",
$today,
$dimension_type,
$dimension_value
));
}
}
protected function is_known_bot_request() {
$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? strtolower((string) wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
if ($user_agent === '') {
return true;
}
$bot_markers = [
'bot', 'crawl', 'crawler', 'spider', 'slurp', 'facebookexternalhit', 'bingpreview',
'headless', 'preview', 'python-requests', 'curl', 'wget', 'uptimerobot', 'monitoring',
'phantomjs', 'feedfetcher', 'google-read-aloud', 'scanner', 'validator', 'scrapy',
'axios', 'go-http-client', 'node-fetch', 'httpclient', 'libwww-perl'
];
foreach ($bot_markers as $marker) {
if (strpos($user_agent, $marker) !== false) {
return true;
}
}
if (!empty($_SERVER['HTTP_X_PURPOSE']) || !empty($_SERVER['HTTP_X_MOZ']) || (!empty($_SERVER['HTTP_SEC_FETCH_SITE']) && strtolower((string) $_SERVER['HTTP_SEC_FETCH_SITE']) === 'none' && strpos($user_agent, 'mozilla') === false)) {
return true;
}
return false;
}
protected function is_trackable_post($post) {
if (!$post || empty($post->ID) || !is_object($post)) {
return false;
}
$post_type_obj = get_post_type_object($post->post_type);
if (!$post_type_obj || empty($post_type_obj->public)) {
return false;
}
if (post_password_required($post)) {
return false;
}
return true;
}
2026-04-18 21:38:56 +00:00
protected function get_landing_page_data(array $page_context) {
$landing_page_key = substr(sanitize_text_field((string) ($page_context['page_key'] ?? '')), 0, 191);
$landing_page_label = sanitize_text_field((string) ($page_context['label'] ?? ''));
$landing_page_url = isset($page_context['url']) ? esc_url_raw((string) $page_context['url']) : '';
return [
'landing_page_key' => $landing_page_key,
'landing_page_label' => $landing_page_label,
'landing_page_url' => $landing_page_url,
];
}
2026-04-15 19:53:03 +00:00
protected function get_referer_data() {
$referer = isset($_SERVER['HTTP_REFERER']) ? trim((string) wp_unslash($_SERVER['HTTP_REFERER'])) : '';
$site_host = wp_parse_url(home_url(), PHP_URL_HOST);
$site_host = $site_host ? strtolower((string) $site_host) : '';
if ($referer === '') {
return [
'category' => 'direct',
'source_url' => '',
'source_host'=> '',
];
}
$host = wp_parse_url($referer, PHP_URL_HOST);
$host = $host ? strtolower((string) $host) : '';
if ($host === '' || $host === $site_host || preg_replace('/^www\./', '', $host) === preg_replace('/^www\./', '', $site_host)) {
return [
'category' => 'direct',
'source_url' => '',
'source_host'=> '',
];
}
$search_hosts = [
'google.', 'bing.com', 'search.yahoo.', 'duckduckgo.com', 'yandex.', 'baidu.com', 'ecosia.org',
'startpage.com', 'qwant.com', 'search.brave.com'
];
foreach ($search_hosts as $needle) {
if (strpos($host, $needle) !== false) {
return [
'category' => 'search',
'source_url' => esc_url_raw($referer),
'source_host'=> sanitize_text_field($host),
];
}
}
$social_hosts = [
'facebook.com', 'm.facebook.com', 'l.facebook.com', 'lm.facebook.com', 'instagram.com',
't.co', 'twitter.com', 'x.com', 'linkedin.com', 'lnkd.in', 'pinterest.', 'reddit.com',
'youtube.com', 'youtu.be', 'tiktok.com', 'snapchat.com', 'threads.net', 'messenger.com'
];
foreach ($social_hosts as $needle) {
if (strpos($host, $needle) !== false) {
return [
'category' => 'social',
'source_url' => esc_url_raw($referer),
'source_host'=> sanitize_text_field($host),
];
}
}
return [
'category' => 'website',
'source_url' => esc_url_raw($referer),
'source_host'=> sanitize_text_field($host),
];
}
public function maybe_track_view() {
if (is_admin() || wp_doing_ajax() || wp_doing_cron() || is_feed() || is_preview() || is_trackback() || is_robots()) {
return;
}
if (!$this->is_trackable_request()) {
return;
}
$settings = $this->get_settings();
if ($this->should_exclude_current_user()) {
return;
}
if (!empty($settings['exclude_known_bots']) && $this->is_known_bot_request()) {
return;
}
$page_context = $this->get_current_page_context();
if (!$page_context || !$this->is_tracking_enabled_for_context($page_context)) {
return;
}
2026-04-18 21:38:56 +00:00
$cookie_name = self::COOKIE_PREFIX . md5($page_context['cookie_key']);
if (isset($_COOKIE[$cookie_name])) {
2026-04-15 19:53:03 +00:00
return;
}
$referer_data = $this->get_referer_data();
2026-04-18 21:38:56 +00:00
$visitor_hash = $this->get_or_create_visitor_hash();
if ($this->is_new_session_visit()) {
$this->register_landing_visit(current_time('Y-m-d'), $visitor_hash, $page_context);
}
2026-04-15 19:53:03 +00:00
if ($page_context['kind'] === 'singular') {
2026-04-18 21:38:56 +00:00
$this->increment_post_views((int) $page_context['post_id'], $page_context, $referer_data, $visitor_hash);
2026-04-15 19:53:03 +00:00
} else {
2026-04-18 21:38:56 +00:00
$this->increment_non_singular_views($page_context, $referer_data, $visitor_hash);
2026-04-15 19:53:03 +00:00
}
if (!headers_sent()) {
2026-04-18 21:38:56 +00:00
setcookie($cookie_name, '1', time() + $this->get_cookie_ttl(), COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true);
$_COOKIE[$cookie_name] = '1';
2026-04-15 19:53:03 +00:00
}
}
protected function is_trackable_request() {
return is_singular() || is_404() || is_home() || is_front_page() || is_post_type_archive() || is_category() || is_tag() || is_tax() || is_author() || is_date() || is_search() || is_archive();
}
protected function normalize_request_uri() {
$uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '/';
$path = wp_parse_url($uri, PHP_URL_PATH);
$path = is_string($path) && $path !== '' ? $path : '/';
return untrailingslashit($path) ?: '/';
}
protected function get_current_page_context() {
if (is_admin()) {
return null;
}
if (is_singular()) {
$post = $this->get_current_singular_post();
if (!$post) {
return null;
}
return [
'kind' => 'singular',
'post_id' => (int) $post->ID,
'post_type' => sanitize_key((string) $post->post_type),
'cookie_key' => 'post:' . (int) $post->ID,
'page_key' => 'post:' . (int) $post->ID,
'page_type' => sanitize_key((string) $post->post_type),
'object_id' => (int) $post->ID,
'label' => get_the_title($post->ID) ?: __('(no title)', 'ansico-stat-plugin'),
'url' => get_permalink($post->ID) ?: '',
];
}
$request_path = $this->normalize_request_uri();
$base = [
'kind' => 'archive',
'cookie_key' => $request_path,
'page_key' => '',
'page_type' => 'archive',
'object_id' => 0,
'label' => '',
'url' => home_url($request_path . '/'),
];
if (is_404()) {
$base['page_type'] = '404';
$base['page_key'] = '404:' . md5($request_path);
$base['label'] = sprintf(__('404: %s', 'ansico-stat-plugin'), $request_path);
$base['url'] = home_url($request_path === '/' ? '/' : $request_path . '/');
return $base;
}
if (is_category() || is_tag() || is_tax()) {
$term = get_queried_object();
if (!$term || empty($term->term_id)) {
return null;
}
$taxonomy = is_string($term->taxonomy ?? '') ? $term->taxonomy : 'taxonomy';
$base['page_type'] = $taxonomy;
$base['page_key'] = 'term:' . $taxonomy . ':' . (int) $term->term_id;
$base['cookie_key'] = $base['page_key'];
$base['object_id'] = (int) $term->term_id;
$base['label'] = single_term_title('', false) ?: ($term->name ?? $request_path);
$base['url'] = get_term_link($term);
return $base;
}
if (is_author()) {
$author = get_queried_object();
if (!$author || empty($author->ID)) {
return null;
}
$base['page_type'] = 'author';
$base['page_key'] = 'author:' . (int) $author->ID;
$base['cookie_key'] = $base['page_key'];
$base['object_id'] = (int) $author->ID;
$base['label'] = get_the_author_meta('display_name', (int) $author->ID) ?: $request_path;
$base['url'] = get_author_posts_url((int) $author->ID);
return $base;
}
if (is_post_type_archive()) {
$post_type = get_query_var('post_type');
$post_type = is_array($post_type) ? reset($post_type) : $post_type;
$post_type = sanitize_key((string) $post_type);
if ($post_type === '') {
$post_type = 'archive';
}
$base['page_type'] = 'post_type_archive';
$base['page_key'] = 'post_type_archive:' . $post_type;
$base['cookie_key'] = $base['page_key'];
$base['label'] = post_type_archive_title('', false) ?: $request_path;
$archive_link = get_post_type_archive_link($post_type);
$base['url'] = $archive_link ?: $base['url'];
return $base;
}
if (is_home() || is_front_page()) {
$base['page_type'] = is_front_page() ? 'front_page' : 'home';
$base['page_key'] = $base['page_type'];
$base['cookie_key'] = $base['page_key'];
$base['label'] = is_front_page() ? __('Front page', 'ansico-stat-plugin') : __('Posts page', 'ansico-stat-plugin');
$base['url'] = home_url('/');
return $base;
}
if (is_date()) {
$base['page_type'] = 'date';
$base['page_key'] = 'date:' . md5($request_path);
$base['label'] = wp_get_document_title() ?: $request_path;
return $base;
}
if (is_search()) {
$search_query = get_search_query();
$base['page_type'] = 'search';
$base['page_key'] = 'search:' . md5((string) $search_query);
$base['cookie_key'] = 'search:' . md5((string) $search_query);
$base['label'] = sprintf(__('Search: %s', 'ansico-stat-plugin'), $search_query ?: __('(empty)', 'ansico-stat-plugin'));
$base['url'] = get_search_link($search_query);
return $base;
}
if (is_archive()) {
$base['page_type'] = 'archive';
$base['page_key'] = 'archive:' . md5($request_path);
$base['label'] = wp_get_document_title() ?: $request_path;
return $base;
}
return null;
}
2026-04-18 21:38:56 +00:00
protected function increment_post_views(int $post_id, array $page_context = [], array $referer_data = [], string $visitor_hash = '') {
2026-04-15 19:53:03 +00:00
global $wpdb;
$today = current_time('Y-m-d');
$daily_table = self::daily_table_name();
$post_daily_table = self::post_daily_table_name();
$page_daily_table = self::page_daily_table_name();
$referral_table = self::referral_table_name();
$current_total = (int) get_post_meta($post_id, self::TOTAL_META_KEY, true);
update_post_meta($post_id, self::TOTAL_META_KEY, $current_total + 1);
$wpdb->query($wpdb->prepare(
"INSERT INTO {$daily_table} (stat_date, views)
VALUES (%s, 1)
ON DUPLICATE KEY UPDATE views = views + 1",
$today
));
$wpdb->query($wpdb->prepare(
2026-04-18 21:38:56 +00:00
"INSERT INTO {$post_daily_table} (post_id, stat_date, views)
VALUES (%d, %s, 1)
ON DUPLICATE KEY UPDATE views = views + 1",
2026-04-15 19:53:03 +00:00
$post_id,
2026-04-18 21:38:56 +00:00
$today
2026-04-15 19:53:03 +00:00
));
$category = isset($referer_data['category']) ? sanitize_key($referer_data['category']) : 'direct';
$source_url = isset($referer_data['source_url']) ? esc_url_raw((string) $referer_data['source_url']) : '';
$source_host = isset($referer_data['source_host']) ? sanitize_text_field((string) $referer_data['source_host']) : '';
2026-04-18 21:38:56 +00:00
$landing_page_data = $this->get_landing_page_data($page_context);
2026-04-15 19:53:03 +00:00
$wpdb->query($wpdb->prepare(
2026-04-18 21:38:56 +00:00
"INSERT INTO {$referral_table} (stat_date, category, source_url, source_host, landing_page_key, landing_page_label, landing_page_url, visits)
VALUES (%s, %s, %s, %s, %s, %s, %s, 1)
ON DUPLICATE KEY UPDATE visits = visits + 1, landing_page_label = VALUES(landing_page_label), landing_page_url = VALUES(landing_page_url)",
2026-04-15 19:53:03 +00:00
$today,
$category,
$source_url,
2026-04-18 21:38:56 +00:00
$source_host,
$landing_page_data['landing_page_key'],
$landing_page_data['landing_page_label'],
$landing_page_data['landing_page_url']
2026-04-15 19:53:03 +00:00
));
2026-04-18 21:38:56 +00:00
$this->register_unique_visitor($today, $visitor_hash, $page_context, $referer_data, $this->get_current_dimension_values());
2026-04-15 19:53:03 +00:00
$this->increment_dimension_views($today);
}
2026-04-18 21:38:56 +00:00
protected function increment_non_singular_views(array $page_context, array $referer_data = [], string $visitor_hash = '') {
2026-04-15 19:53:03 +00:00
global $wpdb;
$today = current_time('Y-m-d');
$daily_table = self::daily_table_name();
$page_daily_table = self::page_daily_table_name();
$referral_table = self::referral_table_name();
$wpdb->query($wpdb->prepare(
"INSERT INTO {$daily_table} (stat_date, views)
VALUES (%s, 1)
ON DUPLICATE KEY UPDATE views = views + 1",
$today
));
$page_key = substr(sanitize_text_field((string) $page_context['page_key']), 0, 191);
$page_type = substr(sanitize_key((string) $page_context['page_type']), 0, 32);
$object_id = !empty($page_context['object_id']) ? (int) $page_context['object_id'] : null;
$label = sanitize_text_field((string) $page_context['label']);
$url = isset($page_context['url']) ? esc_url_raw((string) $page_context['url']) : '';
$wpdb->query($wpdb->prepare(
2026-04-18 21:38:56 +00:00
"INSERT INTO {$page_daily_table} (page_key, page_type, object_id, page_label, page_url, stat_date, views)
VALUES (%s, %s, %s, %s, %s, %s, 1)
ON DUPLICATE KEY UPDATE views = views + 1, page_label = VALUES(page_label), page_url = VALUES(page_url)",
2026-04-15 19:53:03 +00:00
$page_key,
$page_type,
$object_id,
$label,
$url,
2026-04-18 21:38:56 +00:00
$today
2026-04-15 19:53:03 +00:00
));
$category = isset($referer_data['category']) ? sanitize_key($referer_data['category']) : 'direct';
$source_url = isset($referer_data['source_url']) ? esc_url_raw((string) $referer_data['source_url']) : '';
$source_host = isset($referer_data['source_host']) ? sanitize_text_field((string) $referer_data['source_host']) : '';
2026-04-18 21:38:56 +00:00
$landing_page_data = $this->get_landing_page_data($page_context);
2026-04-15 19:53:03 +00:00
$wpdb->query($wpdb->prepare(
2026-04-18 21:38:56 +00:00
"INSERT INTO {$referral_table} (stat_date, category, source_url, source_host, landing_page_key, landing_page_label, landing_page_url, visits)
VALUES (%s, %s, %s, %s, %s, %s, %s, 1)
ON DUPLICATE KEY UPDATE visits = visits + 1, landing_page_label = VALUES(landing_page_label), landing_page_url = VALUES(landing_page_url)",
2026-04-15 19:53:03 +00:00
$today,
$category,
$source_url,
2026-04-18 21:38:56 +00:00
$source_host,
$landing_page_data['landing_page_key'],
$landing_page_data['landing_page_label'],
$landing_page_data['landing_page_url']
2026-04-15 19:53:03 +00:00
));
2026-04-18 21:38:56 +00:00
$this->register_unique_visitor($today, $visitor_hash, $page_context, $referer_data, $this->get_current_dimension_values());
2026-04-15 19:53:03 +00:00
$this->increment_dimension_views($today);
}
protected function get_non_singular_lifetime_views(string $page_key) {
global $wpdb;
$table = self::page_daily_table_name();
$views = $wpdb->get_var($wpdb->prepare(
"SELECT SUM(views) FROM {$table} WHERE page_key = %s",
$page_key
));
return (int) $views;
}
protected function get_current_singular_post() {
if (is_admin() || !is_singular()) {
return null;
}
$post = get_queried_object();
if (!$this->is_trackable_post($post)) {
return null;
}
return $post;
}
protected function get_admin_view_markup(array $page_context) {
if (!$this->is_tracking_enabled_for_context($page_context)) {
return '';
}
$views = 0;
if (($page_context['kind'] ?? '') === 'singular' && !empty($page_context['post_id'])) {
$views = (int) get_post_meta((int) $page_context['post_id'], self::TOTAL_META_KEY, true);
} elseif (!empty($page_context['page_key'])) {
$views = $this->get_non_singular_lifetime_views((string) $page_context['page_key']);
}
$settings = $this->get_settings();
$label = sanitize_text_field((string) ($settings['frontend_label'] ?? 'Views'));
if ($label === '') {
$label = 'Views';
}
$markup = '<div class="ansico-stat-frontend-views" data-ansico-stat-views="1" style="margin:2.75rem 0 1.75rem; padding:1rem 1rem 0; border-top:1px solid #ddd; font-size:14px; opacity:.95; display:flex; align-items:center; gap:.25rem; flex-wrap:wrap;">';
$markup .= $this->get_frontend_icon_markup();
$markup .= '<strong>' . esc_html($label . ':') . '</strong> ' . esc_html(number_format_i18n($views));
$markup .= '</div>';
return $markup;
}
public function append_admin_view_count_to_content($content) {
if (is_admin() || !is_singular() || !in_the_loop() || !is_main_query()) {
return $content;
}
if (!current_user_can('manage_options')) {
return $content;
}
$page_context = $this->get_current_page_context();
if (!$page_context || ($page_context['kind'] ?? '') !== 'singular') {
return $content;
}
$markup = $this->get_admin_view_markup($page_context);
return $markup === '' ? $content : $content . $markup;
}
public function render_admin_view_count_fallback() {
if (is_admin() || !current_user_can('manage_options') || !$this->is_trackable_request()) {
return;
}
if (is_singular()) {
return;
}
$page_context = $this->get_current_page_context();
if (!$page_context) {
return;
}
$markup_value = $this->get_admin_view_markup($page_context);
if ($markup_value === '') {
return;
}
$markup = wp_json_encode($markup_value);
?>
<script>
(function() {
var markup = <?php echo $markup; ?>;
if (!markup || document.querySelector('[data-ansico-stat-views="1"]')) {
return;
}
var selectors = [
'.entry-content', '.post-content', '.content', 'main', 'article',
'.site-main', '.archive-description', '.page-content', '.taxonomy-description'
];
var target = null;
for (var i = 0; i < selectors.length; i++) {
target = document.querySelector(selectors[i]);
if (target) {
break;
}
}
if (!target) {
target = document.querySelector('#secondary, .widget-area, aside, footer, .site-footer');
}
if (!target) {
target = document.body;
}
var wrapper = document.createElement('div');
wrapper.innerHTML = markup;
target.appendChild(wrapper.firstElementChild);
})();
</script>
<?php
}
public function register_admin_page() {
add_menu_page(
__('Ansico Stat Plugin', 'ansico-stat-plugin'),
__('Ansico Stat Plugin', 'ansico-stat-plugin'),
'manage_options',
self::MENU_SLUG_STATS,
[$this, 'render_monthly_stats_page'],
'dashicons-chart-area',
58
);
add_submenu_page(
self::MENU_SLUG_STATS,
__('Monthly statistics', 'ansico-stat-plugin'),
__('Monthly statistics', 'ansico-stat-plugin'),
'manage_options',
self::MENU_SLUG_STATS,
[$this, 'render_monthly_stats_page']
);
add_submenu_page(
self::MENU_SLUG_STATS,
__('Yearly statistics', 'ansico-stat-plugin'),
__('Yearly statistics', 'ansico-stat-plugin'),
'manage_options',
self::MENU_SLUG_YEARLY,
[$this, 'render_yearly_stats_page']
);
2026-04-18 21:38:56 +00:00
add_submenu_page(
self::MENU_SLUG_STATS,
__('Lifetime statistics', 'ansico-stat-plugin'),
__('Lifetime statistics', 'ansico-stat-plugin'),
'manage_options',
self::MENU_SLUG_LIFETIME,
[$this, 'render_lifetime_stats_page']
);
2026-04-17 21:56:44 +00:00
add_submenu_page(
self::MENU_SLUG_STATS,
__('Single page statistics', 'ansico-stat-plugin'),
__('Single page statistics', 'ansico-stat-plugin'),
'manage_options',
self::MENU_SLUG_SINGLE,
[$this, 'render_single_page_stats_page']
);
2026-04-15 19:53:03 +00:00
add_submenu_page(
self::MENU_SLUG_STATS,
__('Settings', 'ansico-stat-plugin'),
__('Settings', 'ansico-stat-plugin'),
'manage_options',
self::MENU_SLUG_SETTINGS,
[$this, 'render_settings_page']
);
}
public function enqueue_admin_assets($hook) {
if (!in_array($hook, [
'index.php',
'toplevel_page_' . self::MENU_SLUG_STATS,
'ansico-stat-plugin_page_' . self::MENU_SLUG_YEARLY,
2026-04-18 21:38:56 +00:00
'ansico-stat-plugin_page_' . self::MENU_SLUG_LIFETIME,
2026-04-17 21:56:44 +00:00
'ansico-stat-plugin_page_' . self::MENU_SLUG_SINGLE,
2026-04-15 19:53:03 +00:00
'ansico-stat-plugin_page_' . self::MENU_SLUG_SETTINGS,
], true)) {
return;
}
wp_enqueue_style(
'ansico-stat-admin',
plugin_dir_url(__FILE__) . 'assets/css/ansico-stat-admin.css',
[],
self::VERSION
);
2026-04-18 21:38:56 +00:00
wp_enqueue_script(
'ansico-stat-admin',
plugin_dir_url(__FILE__) . 'assets/js/ansico-stat-admin.js',
[],
self::VERSION,
true
);
2026-04-15 19:53:03 +00:00
}
public function register_dashboard_widget() {
wp_add_dashboard_widget(
'ansico_stat_dashboard_widget',
2026-04-18 21:38:56 +00:00
__('Ansico Stat Plugin: Daily views and unique visitors', 'ansico-stat-plugin'),
2026-04-15 19:53:03 +00:00
[$this, 'render_dashboard_widget']
);
}
protected function format_post_link(int $post_id, string $label) {
$permalink = get_permalink($post_id);
$label = $label ?: __('(no title)', 'ansico-stat-plugin');
if (!$permalink) {
return esc_html($label);
}
return sprintf(
'<a href="%1$s" title="%2$s" data-permalink="%2$s" target="_blank" rel="noopener noreferrer">%3$s</a>',
esc_url($permalink),
esc_attr($permalink),
esc_html($label)
);
}
protected function format_external_link(string $url) {
if ($url === '') {
return '—';
}
2026-04-18 21:38:56 +00:00
return $this->format_url_display_link($url, $url);
2026-04-15 19:53:03 +00:00
}
2026-04-18 21:38:56 +00:00
protected function get_truncated_url_text(string $text, int $limit = 100) {
$text = trim($text);
if ($text === '') {
return $text;
}
2026-04-15 19:53:03 +00:00
2026-04-18 21:38:56 +00:00
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($text) <= $limit) {
return $text;
}
return rtrim(mb_substr($text, 0, $limit)) . '...';
}
if (strlen($text) <= $limit) {
return $text;
}
return rtrim(substr($text, 0, $limit)) . '...';
}
protected function format_url_display_link(string $url, string $label = '') {
$url = trim($url);
$label = trim($label);
if ($url === '' && $label === '') {
return '—';
}
if ($label === '') {
$label = $url;
}
$display_text = $this->get_truncated_url_text($label, 100);
$copy_label = esc_attr__('Copy URL', 'ansico-stat-plugin');
$copied_label = esc_attr__('Copied', 'ansico-stat-plugin');
if ($url === '') {
return sprintf('<span class="ansico-url-text" title="%1$s">%2$s</span>', esc_attr($label), esc_html($display_text));
}
return sprintf(
'<span class="ansico-url-wrapper"><a class="ansico-url-link" href="%1$s" title="%2$s" data-permalink="%2$s" target="_blank" rel="noopener noreferrer">%3$s</a><button type="button" class="button-link ansico-copy-url" data-url="%4$s" aria-label="%5$s" title="%5$s" data-copied-label="%6$s"><span class="dashicons dashicons-admin-page" aria-hidden="true"></span></button></span>',
esc_url($url),
esc_attr($label),
esc_html($display_text),
esc_attr($url),
$copy_label,
$copied_label
);
}
public function render_dashboard_widget() {
$chart_data = $this->get_daily_chart_data(30);
$monthly_total = array_sum(wp_list_pluck($chart_data, 'views'));
$top_posts = $this->get_top_posts_in_period($this->date_days_ago(29), current_time('Y-m-d'), 10);
echo '<div class="ansico-stat-widget">';
echo '<p class="ansico-stat-widget-total">' . esc_html(sprintf(__('Total tracked views in the last 30 days: %s', 'ansico-stat-plugin'), number_format_i18n($monthly_total))) . '</p>';
echo '<div class="ansico-stat-widget-chart-wrap">';
echo $this->render_multi_line_chart_markup($chart_data, 'ansico-stat-widget-chart', [
'aria_label' => __('Daily views and unique visitors (last 30 days)', 'ansico-stat-plugin'),
'height' => 250,
'width' => 920,
'chart_top' => 12,
'chart_bottom' => 198,
'chart_left' => 44,
'chart_right' => 888,
'x_label_y' => 226,
'max_x_labels' => 8,
]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</div>';
2026-04-15 19:53:03 +00:00
echo '<h4>' . esc_html__('Top 10 views in the last 30 days', 'ansico-stat-plugin') . '</h4>';
if (empty($top_posts)) {
echo '<p>' . esc_html__('No tracked views yet.', 'ansico-stat-plugin') . '</p>';
} else {
echo '<ol class="ansico-stat-top-list">';
foreach ($top_posts as $row) {
$post_id = (int) $row['post_id'];
$title = get_the_title($post_id) ?: __('(no title)', 'ansico-stat-plugin');
echo '<li>';
echo $this->format_post_link($post_id, $title); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo ' <span>(' . esc_html(number_format_i18n((int) $row['period_views'])) . ')</span>';
echo '</li>';
}
echo '</ol>';
}
echo '<p><a class="button button-secondary" href="' . esc_url(admin_url('admin.php?page=' . self::MENU_SLUG_STATS)) . '">' . esc_html__('Open full statistics', 'ansico-stat-plugin') . '</a></p>';
echo '</div>';
}
protected function render_chart_markup(array $chart_data, string $chart_id, array $args = []) {
$labels = [];
$values = [];
foreach ($chart_data as $row) {
$labels[] = wp_date(get_option('date_format'), strtotime($row['date']));
$values[] = (int) ($row['views'] ?? 0);
}
if (empty($chart_data)) {
return '<p>' . esc_html__('No tracked data for this period.', 'ansico-stat-plugin') . '</p>';
}
$max = max($values ?: [0]);
$sum = array_sum($values);
if ($sum <= 0) {
return '<p>' . esc_html__('No tracked data for this period.', 'ansico-stat-plugin') . '</p>';
}
$is_widget = !empty($args['is_widget']);
$height = $is_widget ? 250 : 220;
$width = $is_widget ? 920 : 860;
$chart_top = $is_widget ? 12 : 20;
$chart_bottom = $is_widget ? 198 : 185;
$chart_left = $is_widget ? 44 : 26;
$chart_right = $is_widget ? 888 : 830;
$count = count($values);
$stepX = $count > 1 ? ($chart_right - $chart_left) / ($count - 1) : ($chart_right - $chart_left);
$points = [];
$area_points = [];
$circles = [];
foreach ($values as $index => $value) {
$x = $chart_left + ($stepX * $index);
$y = $chart_bottom;
if ($max > 0) {
$y = $chart_top + (($chart_bottom - $chart_top) * (1 - ($value / $max)));
}
$points[] = round($x, 2) . ',' . round($y, 2);
$area_points[] = round($x, 2) . ',' . round($y, 2);
$circles[] = [
'x' => round($x, 2),
'y' => round($y, 2),
'label' => (string) $labels[$index],
'value' => (int) $value,
];
}
$area_path = '';
if (!empty($area_points)) {
$area_path = $chart_left . ',' . $chart_bottom . ' ' . implode(' ', $area_points) . ' ' . $chart_right . ',' . $chart_bottom;
}
$y_ticks = [];
$tick_count = 5;
for ($i = 0; $i < $tick_count; $i++) {
$ratio = $tick_count > 1 ? $i / ($tick_count - 1) : 0;
$value = (int) round($max * (1 - $ratio));
$y = $chart_top + (($chart_bottom - $chart_top) * $ratio);
$y_ticks[] = [
'value' => $value,
'y' => round($y, 2),
];
}
ob_start();
?>
<div class="ansico-stat-chart-wrap ansico-stat-chart-wrap-enhanced <?php echo $is_widget ? 'ansico-stat-chart-wrap-widget' : ''; ?>" id="<?php echo esc_attr($chart_id); ?>-wrap">
<div class="ansico-stat-line-chart ansico-stat-line-chart-enhanced <?php echo $is_widget ? 'ansico-stat-line-chart-widget' : ''; ?>" id="<?php echo esc_attr($chart_id); ?>">
<svg viewBox="0 0 <?php echo esc_attr((string) $width); ?> <?php echo esc_attr((string) $height); ?>" width="100%" height="<?php echo esc_attr((string) $height); ?>" role="img" aria-label="<?php echo esc_attr__('Views chart', 'ansico-stat-plugin'); ?>">
<defs>
<linearGradient id="<?php echo esc_attr($chart_id . '-gradient'); ?>" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2271b1" stop-opacity="0.24"></stop>
<stop offset="100%" stop-color="#2271b1" stop-opacity="0.03"></stop>
</linearGradient>
</defs>
<?php foreach ($y_ticks as $tick) : ?>
<line x1="<?php echo esc_attr((string) $chart_left); ?>" y1="<?php echo esc_attr((string) $tick['y']); ?>" x2="<?php echo esc_attr((string) $chart_right); ?>" y2="<?php echo esc_attr((string) $tick['y']); ?>" stroke="#e5e7eb" stroke-width="1"></line>
<text x="0" y="<?php echo esc_attr((string) ($tick['y'] + 4)); ?>" font-size="<?php echo esc_attr($is_widget ? '13' : '11'); ?>" fill="#6b7280"><?php echo esc_html(number_format_i18n((int) $tick['value'])); ?></text>
<?php endforeach; ?>
<line x1="<?php echo esc_attr((string) $chart_left); ?>" y1="<?php echo esc_attr((string) $chart_bottom); ?>" x2="<?php echo esc_attr((string) $chart_right); ?>" y2="<?php echo esc_attr((string) $chart_bottom); ?>" stroke="#cbd5e1" stroke-width="1.2"></line>
<polygon fill="url(#<?php echo esc_attr($chart_id . '-gradient'); ?>)" points="<?php echo esc_attr($area_path); ?>"></polygon>
<polyline fill="none" stroke="#2271b1" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="<?php echo esc_attr(implode(' ', $points)); ?>"></polyline>
<?php foreach ($circles as $point) : ?>
<rect class="ansico-stat-chart-point-hit" x="<?php echo esc_attr((string) ($point['x'] - 18)); ?>" y="<?php echo esc_attr((string) ($point['y'] - 18)); ?>" width="36" height="36" rx="18" ry="18" fill="transparent" data-label="<?php echo esc_attr($point['label']); ?>" data-value="<?php echo esc_attr(number_format_i18n($point['value'])); ?>"></rect>
<circle class="ansico-stat-chart-point" cx="<?php echo esc_attr((string) $point['x']); ?>" cy="<?php echo esc_attr((string) $point['y']); ?>" r="3.5" fill="#ffffff" stroke="#2271b1" stroke-width="2.5"></circle>
<?php endforeach; ?>
<?php
$label_every = max(1, (int) ceil($count / 8));
foreach ($circles as $index => $point) :
if ($index % $label_every !== 0 && $index !== ($count - 1)) {
continue;
}
?>
<text x="<?php echo esc_attr((string) $point['x']); ?>" y="<?php echo esc_attr((string) ($is_widget ? 226 : 205)); ?>" text-anchor="middle" font-size="<?php echo esc_attr($is_widget ? '13' : '11'); ?>" fill="#6b7280"><?php echo esc_html($point['label']); ?></text>
<?php endforeach; ?>
</svg>
</div>
<div class="ansico-stat-chart-tooltip" style="display:none; left:-9999px; top:-9999px; opacity:0;" aria-hidden="true"></div>
</div>
<script>
(function() {
var root = document.getElementById(<?php echo wp_json_encode($chart_id); ?>);
var wrap = document.getElementById(<?php echo wp_json_encode($chart_id . '-wrap'); ?>);
if (!root || !wrap) return;
var tooltip = wrap.querySelector('.ansico-stat-chart-tooltip');
var points = root.querySelectorAll('.ansico-stat-chart-point-hit');
if (!tooltip || !points.length) return;
function resetTooltip() {
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
tooltip.style.left = '-9999px';
tooltip.style.top = '-9999px';
}
function positionTooltipForPoint(point) {
var wrapRect = wrap.getBoundingClientRect();
var pointRect = point.getBoundingClientRect();
var tipRect = tooltip.getBoundingClientRect();
var pointCenterX = (pointRect.left - wrapRect.left) + (pointRect.width / 2);
var pointTopY = (pointRect.top - wrapRect.top);
var left = pointCenterX - (tipRect.width / 2);
var top = pointTopY - tipRect.height - 10;
if (left + tipRect.width > wrapRect.width - 8) left = wrapRect.width - tipRect.width - 8;
if (left < 8) left = 8;
if (top < 8) top = pointTopY + 18;
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
function showTooltip(event) {
var point = event.currentTarget;
tooltip.textContent = point.getAttribute('data-label') + ': ' + point.getAttribute('data-value');
tooltip.style.display = 'block';
tooltip.style.opacity = '1';
positionTooltipForPoint(point);
}
points.forEach(function(point) {
point.addEventListener('mouseenter', showTooltip);
point.addEventListener('mouseleave', resetTooltip);
});
wrap.addEventListener('mouseleave', resetTooltip);
resetTooltip();
})();
</script>
<?php
return ob_get_clean();
}
2026-04-18 21:38:56 +00:00
protected function render_multi_line_chart_markup(array $chart_data, string $chart_id, array $args = []) {
$args = wp_parse_args($args, [
'label_key' => 'label',
'x_fallback_key' => 'date',
'views_label' => __('Views', 'ansico-stat-plugin'),
'unique_label' => __('Unique visitors', 'ansico-stat-plugin'),
'aria_label' => __('Views and unique visitors chart', 'ansico-stat-plugin'),
'height' => 240,
'width' => 860,
'chart_top' => 18,
'chart_bottom' => 190,
'chart_left' => 34,
'chart_right' => 830,
'x_label_y' => 216,
'tick_count' => 5,
'max_x_labels' => 8,
]);
if (empty($chart_data)) {
return '<p>' . esc_html__('No tracked data for this period.', 'ansico-stat-plugin') . '</p>';
}
$labels = [];
$full_labels = [];
$views = [];
$unique = [];
foreach ($chart_data as $row) {
$label = '';
if (!empty($row[$args['label_key']])) {
$label = (string) $row[$args['label_key']];
} elseif (!empty($row[$args['x_fallback_key']])) {
$label = (string) $row[$args['x_fallback_key']];
}
$full_label = '';
if (!empty($row['tooltip_label'])) {
$full_label = (string) $row['tooltip_label'];
} elseif (!empty($row[$args['x_fallback_key']])) {
$full_label = (string) $row[$args['x_fallback_key']];
} else {
$full_label = $label;
}
$labels[] = $label;
$full_labels[] = $full_label;
$views[] = (int) ($row['views'] ?? $row['total_views'] ?? 0);
$unique[] = (int) ($row['unique_visitors'] ?? $row['total_unique'] ?? 0);
}
$max = max(array_merge($views, $unique, [0]));
if ($max <= 0) {
return '<p>' . esc_html__('No tracked data for this period.', 'ansico-stat-plugin') . '</p>';
}
$height = (int) $args['height'];
$width = (int) $args['width'];
$chart_top = (float) $args['chart_top'];
$chart_bottom = (float) $args['chart_bottom'];
$chart_left = (float) $args['chart_left'];
$chart_right = (float) $args['chart_right'];
$count = count($labels);
$step_x = $count > 1 ? ($chart_right - $chart_left) / ($count - 1) : 0;
$build_series = function(array $values, string $series_key, string $value_label) use ($count, $labels, $full_labels, $step_x, $chart_left, $chart_right, $chart_bottom, $chart_top, $max) {
$poly_points = [];
$circles = [];
foreach ($values as $index => $value) {
$x = $count > 1 ? $chart_left + ($step_x * $index) : (($chart_left + $chart_right) / 2);
$y = $chart_bottom;
if ($max > 0) {
$y = $chart_bottom - (($value / $max) * ($chart_bottom - $chart_top));
}
$x = round($x, 2);
$y = round($y, 2);
$poly_points[] = $x . ',' . $y;
$circles[] = [
'x' => $x,
'y' => $y,
'label' => (string) ($labels[$index] ?? ''),
'tooltip_label' => (string) ($full_labels[$index] ?? ($labels[$index] ?? '')),
'value' => (int) $value,
'series_key' => $series_key,
'series_label' => $value_label,
];
}
return [
'points' => implode(' ', $poly_points),
'circles' => $circles,
];
};
$view_series = $build_series($views, 'views', (string) $args['views_label']);
$unique_series = $build_series($unique, 'unique', (string) $args['unique_label']);
$y_ticks = [];
$tick_count = max(2, (int) $args['tick_count']);
for ($i = 0; $i < $tick_count; $i++) {
$ratio = $tick_count > 1 ? $i / ($tick_count - 1) : 0;
$value = (int) round($max * (1 - $ratio));
$y = $chart_top + (($chart_bottom - $chart_top) * $ratio);
$y_ticks[] = [
'value' => $value,
'y' => round($y, 2),
];
}
ob_start();
?>
<div class="ansico-stat-chart-wrap ansico-stat-chart-wrap-enhanced ansico-stat-chart-wrap-multi" id="<?php echo esc_attr($chart_id); ?>-wrap">
<div class="ansico-lifetime-legend ansico-stat-chart-legend"><span><span class="ansico-lifetime-swatch ansico-lifetime-swatch-views"></span><?php echo esc_html($args['views_label']); ?></span><span><span class="ansico-lifetime-swatch ansico-lifetime-swatch-unique"></span><?php echo esc_html($args['unique_label']); ?></span></div>
<div class="ansico-stat-line-chart ansico-stat-line-chart-enhanced" id="<?php echo esc_attr($chart_id); ?>">
<svg viewBox="0 0 <?php echo esc_attr((string) $width); ?> <?php echo esc_attr((string) $height); ?>" width="100%" height="<?php echo esc_attr((string) $height); ?>" role="img" aria-label="<?php echo esc_attr($args['aria_label']); ?>">
<?php foreach ($y_ticks as $tick) : ?>
<line x1="<?php echo esc_attr((string) $chart_left); ?>" y1="<?php echo esc_attr((string) $tick['y']); ?>" x2="<?php echo esc_attr((string) $chart_right); ?>" y2="<?php echo esc_attr((string) $tick['y']); ?>" stroke="#e5e7eb" stroke-width="1"></line>
<text x="0" y="<?php echo esc_attr((string) ($tick['y'] + 4)); ?>" font-size="11" fill="#6b7280"><?php echo esc_html(number_format_i18n((int) $tick['value'])); ?></text>
<?php endforeach; ?>
<line x1="<?php echo esc_attr((string) $chart_left); ?>" y1="<?php echo esc_attr((string) $chart_bottom); ?>" x2="<?php echo esc_attr((string) $chart_right); ?>" y2="<?php echo esc_attr((string) $chart_bottom); ?>" stroke="#cbd5e1" stroke-width="1.2"></line>
<polyline fill="none" stroke="#2271b1" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="<?php echo esc_attr($view_series['points']); ?>"></polyline>
<polyline fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="<?php echo esc_attr($unique_series['points']); ?>"></polyline>
<?php foreach ([$view_series['circles'], $unique_series['circles']] as $series_circles) : ?>
<?php foreach ($series_circles as $point) : ?>
<rect class="ansico-stat-chart-point-hit" x="<?php echo esc_attr((string) ($point['x'] - 18)); ?>" y="<?php echo esc_attr((string) ($point['y'] - 18)); ?>" width="36" height="36" rx="18" ry="18" fill="transparent" data-label="<?php echo esc_attr($point['tooltip_label']); ?>" data-value="<?php echo esc_attr(number_format_i18n($point['value'])); ?>" data-series="<?php echo esc_attr($point['series_label']); ?>"></rect>
<circle class="ansico-stat-chart-point ansico-stat-chart-point-<?php echo esc_attr($point['series_key']); ?>" cx="<?php echo esc_attr((string) $point['x']); ?>" cy="<?php echo esc_attr((string) $point['y']); ?>" r="4" fill="#ffffff" stroke="<?php echo esc_attr($point['series_key'] === 'views' ? '#2271b1' : '#16a34a'); ?>" stroke-width="2.5"></circle>
<?php endforeach; ?>
<?php endforeach; ?>
<?php
$label_every = max(1, (int) ceil($count / max(1, (int) $args['max_x_labels'])));
foreach ($labels as $index => $label) :
if ($index % $label_every !== 0 && $index !== ($count - 1)) {
continue;
}
$x = $count > 1 ? $chart_left + ($step_x * $index) : (($chart_left + $chart_right) / 2);
?>
<text x="<?php echo esc_attr((string) round($x, 2)); ?>" y="<?php echo esc_attr((string) $args['x_label_y']); ?>" text-anchor="middle" font-size="11" fill="#6b7280"><?php echo esc_html($label); ?></text>
<?php endforeach; ?>
</svg>
</div>
<div class="ansico-stat-chart-tooltip" style="display:none; left:-9999px; top:-9999px; opacity:0;" aria-hidden="true"></div>
</div>
<script>
(function() {
var root = document.getElementById(<?php echo wp_json_encode($chart_id); ?>);
var wrap = document.getElementById(<?php echo wp_json_encode($chart_id . '-wrap'); ?>);
if (!root || !wrap) return;
var tooltip = wrap.querySelector('.ansico-stat-chart-tooltip');
var points = root.querySelectorAll('.ansico-stat-chart-point-hit');
if (!tooltip || !points.length) return;
function resetTooltip() {
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
tooltip.style.left = '-9999px';
tooltip.style.top = '-9999px';
}
function positionTooltipForPoint(point) {
var wrapRect = wrap.getBoundingClientRect();
var pointRect = point.getBoundingClientRect();
var tipRect = tooltip.getBoundingClientRect();
var pointCenterX = (pointRect.left - wrapRect.left) + (pointRect.width / 2);
var pointTopY = (pointRect.top - wrapRect.top);
var left = pointCenterX - (tipRect.width / 2);
var top = pointTopY - tipRect.height - 10;
if (left + tipRect.width > wrapRect.width - 8) left = wrapRect.width - tipRect.width - 8;
if (left < 8) left = 8;
if (top < 8) top = pointTopY + 18;
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
function showTooltip(event) {
var point = event.currentTarget;
tooltip.textContent = point.getAttribute('data-label') + ' — ' + point.getAttribute('data-series') + ': ' + point.getAttribute('data-value');
tooltip.style.display = 'block';
tooltip.style.opacity = '1';
positionTooltipForPoint(point);
}
points.forEach(function(point) {
point.addEventListener('mouseenter', showTooltip);
point.addEventListener('mouseleave', resetTooltip);
});
wrap.addEventListener('mouseleave', resetTooltip);
resetTooltip();
})();
</script>
<?php
return ob_get_clean();
}
protected function render_bar_chart_markup(array $chart_data, string $chart_id, string $aria_label = '', array $args = []) {
2026-04-15 19:53:03 +00:00
if ($aria_label === '') {
$aria_label = __('Chart', 'ansico-stat-plugin');
}
2026-04-18 21:38:56 +00:00
$args = wp_parse_args($args, [
'orientation' => 'vertical',
]);
$orientation = $args['orientation'] === 'horizontal' ? 'horizontal' : 'vertical';
2026-04-15 19:53:03 +00:00
$values = [];
foreach ($chart_data as $row) {
$values[] = (int) ($row['views'] ?? $row['total_views'] ?? 0);
}
$max = max($values ?: [0]);
$sum = array_sum($values);
2026-04-18 21:38:56 +00:00
$item_count = count($chart_data);
$slot_width = 52;
$bar_width = 28;
$row_height = 26;
if ($item_count >= 10) {
$slot_width = 40;
$bar_width = 18;
$row_height = 22;
} elseif ($item_count >= 8) {
$slot_width = 44;
$bar_width = 20;
$row_height = 24;
} elseif ($item_count >= 6) {
$slot_width = 48;
$bar_width = 24;
$row_height = 25;
}
2026-04-15 19:53:03 +00:00
ob_start();
?>
2026-04-18 21:38:56 +00:00
<div class="ansico-stat-bar-chart ansico-stat-bar-chart-wrap ansico-stat-bar-chart--<?php echo esc_attr($orientation); ?>" id="<?php echo esc_attr($chart_id); ?>">
2026-04-15 19:53:03 +00:00
<?php if (empty($chart_data) || $sum <= 0) : ?>
<p><?php echo esc_html__('No tracked data for this period.', 'ansico-stat-plugin'); ?></p>
<?php else : ?>
2026-04-18 21:38:56 +00:00
<?php if ($orientation === 'horizontal') : ?>
<div class="ansico-stat-hbar-chart" style="--ansico-hbar-count: <?php echo esc_attr((string) $item_count); ?>; --ansico-hbar-row-height: <?php echo esc_attr((string) $row_height); ?>px;">
<?php foreach ($chart_data as $row) :
$value = (int) ($row['views'] ?? $row['total_views'] ?? 0);
$label = (string) ($row['label'] ?? $row['date'] ?? $row['dimension_label'] ?? $row['dimension_value'] ?? '');
$width = ($max > 0 && $value > 0) ? max(2, (int) round(($value / $max) * 100)) : 0;
?>
<div class="ansico-stat-hbar-item">
<div class="ansico-stat-hbar-label"><?php echo esc_html($label); ?></div>
<div class="ansico-stat-hbar-track">
<div class="ansico-stat-hbar-fill ansico-stat-bar" style="width:<?php echo esc_attr((string) $width); ?>%;" data-label="<?php echo esc_attr($label); ?>" data-value="<?php echo esc_attr(number_format_i18n($value)); ?>"></div>
</div>
<div class="ansico-stat-hbar-value"><?php echo esc_html(number_format_i18n($value)); ?></div>
</div>
<?php endforeach; ?>
</div>
<?php else : ?>
<div class="ansico-stat-bar-chart-grid" style="--ansico-bar-count: <?php echo esc_attr((string) $item_count); ?>; --ansico-bar-slot-width: <?php echo esc_attr((string) $slot_width); ?>px; --ansico-bar-width: <?php echo esc_attr((string) $bar_width); ?>px;">
<?php foreach ($chart_data as $row) :
$value = (int) ($row['views'] ?? $row['total_views'] ?? 0);
$label = (string) ($row['label'] ?? $row['date'] ?? $row['dimension_label'] ?? $row['dimension_value'] ?? '');
$height = ($max > 0 && $value > 0) ? max(6, (int) round(($value / $max) * 140)) : 0;
?>
<div class="ansico-stat-bar-item">
<div class="ansico-stat-bar" style="height:<?php echo esc_attr((string) $height); ?>px;" data-label="<?php echo esc_attr($label); ?>" data-value="<?php echo esc_attr(number_format_i18n($value)); ?>"></div>
<div class="ansico-stat-bar-value"><?php echo esc_html(number_format_i18n($value)); ?></div>
<div class="ansico-stat-bar-label"><?php echo esc_html($label); ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
2026-04-15 19:53:03 +00:00
<div class="ansico-stat-chart-tooltip" style="display:none; left:-9999px; top:-9999px; opacity:0;" aria-hidden="true"></div>
<script>
(function() {
var root = document.getElementById(<?php echo wp_json_encode($chart_id); ?>);
if (!root) return;
var tooltip = root.querySelector('.ansico-stat-chart-tooltip');
var items = root.querySelectorAll('.ansico-stat-bar');
if (!tooltip || !items.length) return;
function resetTooltip() {
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
tooltip.style.left = '-9999px';
tooltip.style.top = '-9999px';
}
2026-04-18 21:38:56 +00:00
function positionTooltip(item) {
2026-04-15 19:53:03 +00:00
var rootRect = root.getBoundingClientRect();
var itemRect = item.getBoundingClientRect();
var tipRect = tooltip.getBoundingClientRect();
2026-04-18 21:38:56 +00:00
var isHorizontal = root.classList.contains('ansico-stat-bar-chart--horizontal');
var left = (itemRect.left - rootRect.left) + (itemRect.width / 2) - (tipRect.width / 2);
var top = (itemRect.top - rootRect.top) - tipRect.height - 8;
2026-04-15 19:53:03 +00:00
2026-04-18 21:38:56 +00:00
if (isHorizontal) {
left = (itemRect.right - rootRect.left) - tipRect.width;
}
2026-04-15 19:53:03 +00:00
if (left < 8) left = 8;
2026-04-18 21:38:56 +00:00
if (left + tipRect.width > rootRect.width - 8) left = rootRect.width - tipRect.width - 8;
if (top < 8) top = (itemRect.bottom - rootRect.top) + 8;
2026-04-15 19:53:03 +00:00
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
function showTooltip(event) {
var item = event.currentTarget;
tooltip.textContent = item.getAttribute('data-label') + ': ' + item.getAttribute('data-value');
tooltip.style.display = 'block';
tooltip.style.opacity = '1';
2026-04-18 21:38:56 +00:00
positionTooltip(item);
2026-04-15 19:53:03 +00:00
}
items.forEach(function(item) {
item.addEventListener('mouseenter', showTooltip);
2026-04-18 21:38:56 +00:00
item.addEventListener('mousemove', showTooltip);
2026-04-15 19:53:03 +00:00
item.addEventListener('mouseleave', resetTooltip);
});
root.addEventListener('mouseleave', resetTooltip);
resetTooltip();
})();
</script>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
protected function render_pie_chart_markup(array $segments, string $chart_id, string $aria_label = '') {
if ($aria_label === '') {
$aria_label = __('Pie chart', 'ansico-stat-plugin');
}
$filtered = [];
foreach ($segments as $label => $value) {
$value = (int) $value;
if ($value > 0) {
$filtered[(string) $label] = $value;
}
}
if (empty($filtered)) {
return '<p>' . esc_html__('No tracked data for this period.', 'ansico-stat-plugin') . '</p>';
}
$total = array_sum($filtered);
$palette = ['#2271b1', '#72aee6', '#135e96', '#8c8f94', '#dba617', '#00a32a', '#d63638', '#50575e'];
$cx = 90;
$cy = 90;
$r = 72;
$startAngle = -90;
$index = 0;
ob_start();
?>
<div class="ansico-stat-pie-wrap" id="<?php echo esc_attr($chart_id); ?>">
<div class="ansico-stat-pie-chart-shell">
<svg viewBox="0 0 260 180" width="100%" height="180" role="img" aria-label="<?php echo esc_attr($aria_label); ?>">
<?php foreach ($filtered as $label => $value) :
$angle = ($value / $total) * 360;
$endAngle = $startAngle + $angle;
$x1 = $cx + $r * cos(deg2rad($startAngle));
$y1 = $cy + $r * sin(deg2rad($startAngle));
$x2 = $cx + $r * cos(deg2rad($endAngle));
$y2 = $cy + $r * sin(deg2rad($endAngle));
$largeArc = $angle > 180 ? 1 : 0;
$color = $palette[$index % count($palette)];
$percent = $total > 0 ? number_format_i18n(($value / $total) * 100, 1) . '%' : '0%';
?>
<path class="ansico-stat-pie-segment" d="M <?php echo esc_attr((string) $cx); ?> <?php echo esc_attr((string) $cy); ?> L <?php echo esc_attr(round($x1, 3)); ?> <?php echo esc_attr(round($y1, 3)); ?> A <?php echo esc_attr((string) $r); ?> <?php echo esc_attr((string) $r); ?> 0 <?php echo esc_attr((string) $largeArc); ?> 1 <?php echo esc_attr(round($x2, 3)); ?> <?php echo esc_attr(round($y2, 3)); ?> Z" fill="<?php echo esc_attr($color); ?>" data-label="<?php echo esc_attr($label); ?>" data-value="<?php echo esc_attr(number_format_i18n($value)); ?>" data-percent="<?php echo esc_attr($percent); ?>"></path>
<?php
$startAngle = $endAngle;
$index++;
endforeach; ?>
<circle cx="<?php echo esc_attr((string) $cx); ?>" cy="<?php echo esc_attr((string) $cy); ?>" r="32" fill="#fff"></circle>
<text x="<?php echo esc_attr((string) $cx); ?>" y="<?php echo esc_attr((string) ($cy + 4)); ?>" text-anchor="middle" font-size="12"><?php echo esc_html(number_format_i18n($total)); ?></text>
</svg>
<div class="ansico-stat-chart-tooltip" style="display:none; left:-9999px; top:-9999px; opacity:0;" aria-hidden="true"></div>
</div>
<div class="ansico-stat-pie-legend">
<?php $index = 0; foreach ($filtered as $label => $value) : $color = $palette[$index % count($palette)]; $percent = $total > 0 ? number_format_i18n(($value / $total) * 100, 1) . '%' : '0%'; ?>
<div class="ansico-stat-legend-item">
<span class="ansico-stat-legend-swatch" style="background:<?php echo esc_attr($color); ?>"></span>
<span><?php echo esc_html($label); ?> (<?php echo esc_html(number_format_i18n($value)); ?> · <?php echo esc_html($percent); ?>)</span>
</div>
<?php $index++; endforeach; ?>
</div>
</div>
<script>
(function() {
var root = document.getElementById(<?php echo wp_json_encode($chart_id); ?>);
if (!root) return;
var tooltip = root.querySelector('.ansico-stat-chart-tooltip');
var segments = root.querySelectorAll('.ansico-stat-pie-segment');
var host = root.querySelector('.ansico-stat-pie-chart-shell');
if (!tooltip || !segments.length || !host) return;
function resetTooltip() {
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
tooltip.style.left = '-9999px';
tooltip.style.top = '-9999px';
}
function positionTooltip(event) {
var rect = host.getBoundingClientRect();
var tipRect = tooltip.getBoundingClientRect();
var x = event.clientX - rect.left;
var y = event.clientY - rect.top;
var left = x - (tipRect.width / 2);
var top = y - tipRect.height - 12;
if (left + tipRect.width > rect.width - 8) left = rect.width - tipRect.width - 8;
if (left < 8) left = 8;
if (top < 8) top = y + 16;
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
function showTooltip(event) {
var segment = event.currentTarget;
tooltip.textContent = segment.getAttribute('data-label') + ': ' + segment.getAttribute('data-value') + ' (' + segment.getAttribute('data-percent') + ')';
tooltip.style.display = 'block';
tooltip.style.opacity = '1';
positionTooltip(event);
}
segments.forEach(function(segment) {
segment.addEventListener('mouseenter', showTooltip);
segment.addEventListener('mousemove', function(event) {
if (tooltip.style.display === 'none') return;
positionTooltip(event);
});
segment.addEventListener('mouseleave', resetTooltip);
});
host.addEventListener('mouseleave', resetTooltip);
resetTooltip();
})();
</script>
<?php
return ob_get_clean();
}
protected function date_days_ago(int $days) {
return wp_date('Y-m-d', strtotime('-' . max(0, $days) . ' days', current_time('timestamp')));
}
protected function get_daily_chart_data(int $days = 30) {
global $wpdb;
$days = max(1, $days);
$daily_table = self::daily_table_name();
2026-04-18 21:38:56 +00:00
$unique_table = self::unique_total_table_name();
2026-04-15 19:53:03 +00:00
$start = $this->date_days_ago($days - 1);
$end = current_time('Y-m-d');
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT stat_date, views FROM {$daily_table} WHERE stat_date BETWEEN %s AND %s ORDER BY stat_date ASC",
$start,
$end
), ARRAY_A);
2026-04-18 21:38:56 +00:00
$unique_rows = $wpdb->get_results($wpdb->prepare(
"SELECT period_key, COUNT(*) AS total_unique FROM {$unique_table} WHERE period_type = %s AND period_key BETWEEN %s AND %s GROUP BY period_key ORDER BY period_key ASC",
'day',
$start,
$end
), ARRAY_A);
2026-04-15 19:53:03 +00:00
$indexed = [];
foreach ($rows as $row) {
$indexed[$row['stat_date']] = (int) $row['views'];
}
2026-04-18 21:38:56 +00:00
$unique_indexed = [];
foreach ($unique_rows as $row) {
$unique_indexed[(string) ($row['period_key'] ?? '')] = (int) ($row['total_unique'] ?? 0);
}
2026-04-15 19:53:03 +00:00
$data = [];
$cursor = strtotime($start);
$end_ts = strtotime($end);
while ($cursor <= $end_ts) {
$date = gmdate('Y-m-d', $cursor);
$data[] = [
'date' => $date,
2026-04-18 21:38:56 +00:00
'label' => wp_date(get_option('date_format'), strtotime($date)),
2026-04-15 19:53:03 +00:00
'views' => $indexed[$date] ?? 0,
2026-04-18 21:38:56 +00:00
'unique_visitors' => $unique_indexed[$date] ?? 0,
2026-04-15 19:53:03 +00:00
];
$cursor = strtotime('+1 day', $cursor);
}
return $data;
}
protected function get_install_month_key() {
$install_date = get_option(self::INSTALL_OPTION_KEY, current_time('Y-m-d'));
$timestamp = strtotime($install_date ?: current_time('Y-m-d'));
return wp_date('Y-m', $timestamp ?: current_time('timestamp'));
}
protected function get_install_year() {
$install_date = get_option(self::INSTALL_OPTION_KEY, current_time('Y-m-d'));
$timestamp = strtotime($install_date ?: current_time('Y-m-d'));
return (int) wp_date('Y', $timestamp ?: current_time('timestamp'));
}
protected function get_selected_month() {
$requested = isset($_GET['month']) ? sanitize_text_field(wp_unslash($_GET['month'])) : '';
if ($requested && preg_match('/^\d{4}-\d{2}$/', $requested)) {
return $requested;
}
return wp_date('Y-m', current_time('timestamp'));
}
protected function get_selected_year() {
$requested = isset($_GET['year']) ? absint(wp_unslash($_GET['year'])) : 0;
if ($requested >= 1970 && $requested <= 9999) {
return $requested;
}
return (int) wp_date('Y', current_time('timestamp'));
}
protected function render_month_navigation(string $selected_month) {
$selected_ts = strtotime($selected_month . '-01');
$current_ts = strtotime(wp_date('Y-m-01', current_time('timestamp')));
$prev_month = wp_date('Y-m', strtotime('-1 month', $selected_ts));
$next_month = wp_date('Y-m', strtotime('+1 month', $selected_ts));
$base_url = admin_url('admin.php?page=' . self::MENU_SLUG_STATS);
echo '<div class="ansico-stat-nav" style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin:0 0 1rem;">';
echo '<a class="button button-secondary" href="' . esc_url(add_query_arg(['month' => $prev_month, 'year' => (int) wp_date('Y', $selected_ts)], $base_url)) . '">&larr; ' . esc_html__('Older month', 'ansico-stat-plugin') . '</a>';
echo '<strong>' . esc_html(wp_date('F Y', $selected_ts)) . '</strong>';
if ($selected_ts < $current_ts) {
echo '<a class="button button-secondary" href="' . esc_url(add_query_arg(['month' => $next_month, 'year' => (int) wp_date('Y', strtotime($next_month . '-01'))], $base_url)) . '">' . esc_html__('Newer month', 'ansico-stat-plugin') . ' &rarr;</a>';
} else {
echo '<span></span>';
}
echo '</div>';
}
protected function render_year_navigation(int $selected_year) {
$current_year = (int) wp_date('Y', current_time('timestamp'));
$current_page = isset($_GET['page']) ? sanitize_key((string) wp_unslash($_GET['page'])) : self::MENU_SLUG_STATS;
$target_slug = $current_page === self::MENU_SLUG_YEARLY ? self::MENU_SLUG_YEARLY : self::MENU_SLUG_STATS;
$base_url = admin_url('admin.php?page=' . $target_slug);
echo '<div class="ansico-stat-nav" style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin:0 0 1rem;">';
echo '<a class="button button-secondary" href="' . esc_url(add_query_arg(['year' => $selected_year - 1, 'month' => $this->get_selected_month()], $base_url)) . '">&larr; ' . esc_html__('Older year', 'ansico-stat-plugin') . '</a>';
echo '<strong>' . esc_html((string) $selected_year) . '</strong>';
if ($selected_year < $current_year) {
echo '<a class="button button-secondary" href="' . esc_url(add_query_arg(['year' => $selected_year + 1, 'month' => $this->get_selected_month()], $base_url)) . '">' . esc_html__('Newer year', 'ansico-stat-plugin') . ' &rarr;</a>';
} else {
echo '<span></span>';
}
echo '</div>';
}
protected function get_top_posts_in_period(string $start, string $end, int $limit = 10) {
global $wpdb;
$table = self::post_daily_table_name();
$results = $wpdb->get_results($wpdb->prepare(
"SELECT post_id, SUM(views) AS period_views
FROM {$table}
WHERE stat_date BETWEEN %s AND %s
GROUP BY post_id
ORDER BY period_views DESC
LIMIT %d",
$start,
$end,
$limit
), ARRAY_A);
return is_array($results) ? $results : [];
}
protected function get_monthly_top_posts(string $month_key, int $limit = 10) {
$start = $month_key . '-01';
$end = wp_date('Y-m-t', strtotime($start));
2026-04-18 21:38:56 +00:00
$rows = $this->get_top_posts_in_period($start, $end, $limit);
return $this->apply_unique_counts_to_post_rows($rows, 'month', $month_key);
2026-04-15 19:53:03 +00:00
}
protected function get_yearly_top_posts(int $year, int $limit = 10) {
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
2026-04-18 21:38:56 +00:00
$rows = $this->get_top_posts_in_period($start, $end, $limit);
return $this->apply_unique_counts_to_post_rows($rows, 'year', (string) $year);
2026-04-15 19:53:03 +00:00
}
protected function get_latest_month_range() {
return [
'start' => wp_date('Y-m-01', current_time('timestamp')),
'end' => wp_date('Y-m-t', current_time('timestamp')),
];
}
protected function get_post_views_for_latest_month(int $post_id) {
global $wpdb;
$range = $this->get_latest_month_range();
$table = self::post_daily_table_name();
$views = $wpdb->get_var($wpdb->prepare(
"SELECT SUM(views)
FROM {$table}
WHERE post_id = %d
AND stat_date BETWEEN %s AND %s",
$post_id,
$range['start'],
$range['end']
));
return (int) $views;
}
2026-04-18 21:38:56 +00:00
protected function get_unique_total_count(string $period_type, string $period_key) {
global $wpdb;
$table = self::unique_total_table_name();
$count = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE period_type = %s AND period_key = %s",
$period_type,
$period_key
));
return (int) $count;
}
protected function get_unique_page_counts(string $period_type, string $period_key, array $page_keys) {
global $wpdb;
$table = self::unique_page_table_name();
$page_keys = array_values(array_filter(array_map(function($value) {
return substr(sanitize_text_field((string) $value), 0, 191);
}, $page_keys)));
if (empty($page_keys)) {
return [];
}
$placeholders = implode(', ', array_fill(0, count($page_keys), '%s'));
$params = array_merge([$period_type, $period_key], $page_keys);
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT page_key, COUNT(*) AS total_unique FROM {$table} WHERE period_type = %s AND period_key = %s AND page_key IN ({$placeholders}) GROUP BY page_key",
$params
), ARRAY_A);
$map = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$map[(string) ($row['page_key'] ?? '')] = (int) ($row['total_unique'] ?? 0);
}
}
return $map;
}
protected function apply_unique_counts_to_post_rows(array $rows, string $period_type, string $period_key) {
if (empty($rows)) {
return [];
}
$page_keys = [];
foreach ($rows as $row) {
$page_keys[] = 'post:' . (int) ($row['post_id'] ?? 0);
}
$map = $this->get_unique_page_counts($period_type, $period_key, $page_keys);
foreach ($rows as &$row) {
$page_key = 'post:' . (int) ($row['post_id'] ?? 0);
$row['unique_visitors'] = (int) ($map[$page_key] ?? 0);
}
unset($row);
return $rows;
}
protected function get_top_5_plus_others_segments(array $rows) {
$segments = [];
$other_total = 0;
foreach (array_values($rows) as $index => $row) {
$label = (string) ($row['dimension_label'] ?? $row['dimension_value'] ?? $row['label'] ?? '');
$value = (int) ($row['total_views'] ?? $row['views'] ?? 0);
if ($value <= 0) {
continue;
}
if ($index < 5) {
$segments[$label] = $value;
} else {
$other_total += $value;
}
}
if ($other_total > 0) {
$segments[__('Others', 'ansico-stat-plugin')] = $other_total;
}
return $segments;
}
2026-04-15 19:53:03 +00:00
protected function get_referral_summary_for_month(string $month_key) {
global $wpdb;
$table = self::referral_table_name();
$start = $month_key . '-01';
$end = wp_date('Y-m-t', strtotime($start));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT category, SUM(visits) AS total_visits
FROM {$table}
WHERE stat_date BETWEEN %s AND %s
GROUP BY category",
$start,
$end
), ARRAY_A);
$summary = [
2026-04-18 21:38:56 +00:00
'direct' => ['visits' => 0, 'unique_visitors' => 0],
'search' => ['visits' => 0, 'unique_visitors' => 0],
'social' => ['visits' => 0, 'unique_visitors' => 0],
'website' => ['visits' => 0, 'unique_visitors' => 0],
2026-04-15 19:53:03 +00:00
];
foreach ($rows as $row) {
$category = isset($row['category']) ? (string) $row['category'] : '';
if (isset($summary[$category])) {
2026-04-18 21:38:56 +00:00
$summary[$category]['visits'] = (int) $row['total_visits'];
}
}
$unique_table = self::unique_referral_table_name();
$unique_rows = $wpdb->get_results($wpdb->prepare(
"SELECT category, COUNT(*) AS total_unique
FROM {$unique_table}
WHERE period_type = %s
AND period_key = %s
GROUP BY category",
'month',
$month_key
), ARRAY_A);
if (is_array($unique_rows)) {
foreach ($unique_rows as $row) {
$category = isset($row['category']) ? (string) $row['category'] : '';
if (isset($summary[$category])) {
$summary[$category]['unique_visitors'] = (int) ($row['total_unique'] ?? 0);
}
2026-04-15 19:53:03 +00:00
}
}
return $summary;
}
2026-04-18 21:38:56 +00:00
protected function get_referral_sources_for_month(string $month_key, string $category, ?int $limit = null) {
2026-04-15 19:53:03 +00:00
global $wpdb;
$table = self::referral_table_name();
$start = $month_key . '-01';
$end = wp_date('Y-m-t', strtotime($start));
2026-04-18 21:38:56 +00:00
$unique_table = self::unique_referral_table_name();
$sql = $wpdb->prepare(
"SELECT r.source_url, r.source_host, r.landing_page_label, r.landing_page_url, SUM(r.visits) AS total_visits,
(
SELECT COUNT(*)
FROM {$unique_table} ur
WHERE ur.period_type = %s
AND ur.period_key = %s
AND ur.category = r.category
AND ur.source_url = LEFT(COALESCE(r.source_url, ''), 191)
AND ur.source_host = COALESCE(r.source_host, '')
AND ur.landing_page_key = COALESCE(r.landing_page_key, '')
) AS total_unique
FROM {$table} r
WHERE r.stat_date BETWEEN %s AND %s
AND r.category = %s
AND r.source_url <> ''
GROUP BY r.source_url, r.source_host, r.landing_page_key, r.landing_page_label, r.landing_page_url, r.category
ORDER BY total_visits DESC, source_host ASC, landing_page_label ASC",
'month',
$month_key,
2026-04-15 19:53:03 +00:00
$start,
$end,
2026-04-18 21:38:56 +00:00
$category
);
if ($limit !== null && $limit > 0) {
$sql .= $wpdb->prepare(' LIMIT %d', $limit);
}
$rows = $wpdb->get_results($sql, ARRAY_A);
2026-04-15 19:53:03 +00:00
return is_array($rows) ? $rows : [];
}
protected function get_top_404_pages_for_month(string $month_key, int $limit = 10) {
global $wpdb;
$table = self::page_daily_table_name();
$start = $month_key . '-01';
$end = wp_date('Y-m-t', strtotime($start));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT page_key, page_label, page_url, SUM(views) AS period_views
FROM {$table}
WHERE stat_date BETWEEN %s AND %s
AND page_type = %s
GROUP BY page_key, page_label, page_url
ORDER BY period_views DESC, page_url ASC
LIMIT %d",
$start,
$end,
'404',
$limit
), ARRAY_A);
2026-04-18 21:38:56 +00:00
$rows = is_array($rows) ? $rows : [];
$unique_map = $this->get_unique_page_counts('month', $month_key, array_column($rows, 'page_key'));
foreach ($rows as &$row) {
$row['unique_visitors'] = (int) ($unique_map[(string) ($row['page_key'] ?? '')] ?? 0);
}
unset($row);
return $rows;
}
protected function get_monthly_top_landing_pages(string $month_key, int $limit = 10) {
global $wpdb;
$table = self::landing_table_name();
$unique_table = self::unique_landing_table_name();
$start = $month_key . '-01';
$end = wp_date('Y-m-t', strtotime($start));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT l.page_key, l.page_label, l.page_url, SUM(l.visits) AS total_views,
(
SELECT COUNT(*)
FROM {$unique_table} ul
WHERE ul.period_type = %s
AND ul.period_key = %s
AND ul.page_key = l.page_key
) AS total_unique
FROM {$table} l
WHERE l.stat_date BETWEEN %s AND %s
GROUP BY l.page_key, l.page_label, l.page_url
ORDER BY total_views DESC, l.page_label ASC
LIMIT %d",
'month',
$month_key,
$start,
$end,
$limit
), ARRAY_A);
2026-04-15 19:53:03 +00:00
return is_array($rows) ? $rows : [];
}
2026-04-18 21:38:56 +00:00
protected function get_yearly_top_landing_pages(int $year, int $limit = 10) {
global $wpdb;
$table = self::landing_table_name();
$unique_table = self::unique_landing_table_name();
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT l.page_key, l.page_label, l.page_url, SUM(l.visits) AS total_views,
(
SELECT COUNT(*)
FROM {$unique_table} ul
WHERE ul.period_type = %s
AND ul.period_key = %s
AND ul.page_key = l.page_key
) AS total_unique
FROM {$table} l
WHERE l.stat_date BETWEEN %s AND %s
GROUP BY l.page_key, l.page_label, l.page_url
ORDER BY total_views DESC, l.page_label ASC
LIMIT %d",
'year',
(string) $year,
$start,
$end,
$limit
), ARRAY_A);
return is_array($rows) ? $rows : [];
}
2026-04-15 19:53:03 +00:00
protected function get_dimension_rows_for_month(string $month_key, string $dimension_type, int $limit = 10) {
global $wpdb;
$table = self::dimension_table_name();
$start = $month_key . '-01';
$end = wp_date('Y-m-t', strtotime($start));
2026-04-18 21:38:56 +00:00
$unique_table = self::unique_dimension_table_name();
2026-04-15 19:53:03 +00:00
$rows = $wpdb->get_results($wpdb->prepare(
2026-04-18 21:38:56 +00:00
"SELECT d.dimension_value, SUM(d.views) AS total_views,
(
SELECT COUNT(*)
FROM {$unique_table} ud
WHERE ud.period_type = %s
AND ud.period_key = %s
AND ud.dimension_type = d.dimension_type
AND ud.dimension_value = d.dimension_value
) AS total_unique
FROM {$table} d
WHERE d.stat_date BETWEEN %s AND %s
AND d.dimension_type = %s
GROUP BY d.dimension_value, d.dimension_type
ORDER BY total_views DESC, d.dimension_value ASC
2026-04-15 19:53:03 +00:00
LIMIT %d",
2026-04-18 21:38:56 +00:00
'month',
$month_key,
2026-04-15 19:53:03 +00:00
$start,
$end,
$dimension_type,
$limit
), ARRAY_A);
if (!is_array($rows)) {
return [];
}
if ($dimension_type === 'country') {
foreach ($rows as &$row) {
$row['dimension_label'] = $this->get_country_label((string) ($row['dimension_value'] ?? ''));
}
unset($row);
}
return $rows;
}
protected function get_dimension_rows_for_year(int $year, string $dimension_type, int $limit = 10) {
global $wpdb;
$table = self::dimension_table_name();
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
2026-04-18 21:38:56 +00:00
$unique_table = self::unique_dimension_table_name();
2026-04-15 19:53:03 +00:00
$rows = $wpdb->get_results($wpdb->prepare(
2026-04-18 21:38:56 +00:00
"SELECT d.dimension_value, SUM(d.views) AS total_views,
(
SELECT COUNT(*)
FROM {$unique_table} ud
WHERE ud.period_type = %s
AND ud.period_key = %s
AND ud.dimension_type = d.dimension_type
AND ud.dimension_value = d.dimension_value
) AS total_unique
FROM {$table} d
WHERE d.stat_date BETWEEN %s AND %s
AND d.dimension_type = %s
GROUP BY d.dimension_value, d.dimension_type
ORDER BY total_views DESC, d.dimension_value ASC
2026-04-15 19:53:03 +00:00
LIMIT %d",
2026-04-18 21:38:56 +00:00
'year',
(string) $year,
2026-04-15 19:53:03 +00:00
$start,
$end,
$dimension_type,
$limit
), ARRAY_A);
if (!is_array($rows)) {
return [];
}
if ($dimension_type === 'country') {
foreach ($rows as &$row) {
$row['dimension_label'] = $this->get_country_label((string) ($row['dimension_value'] ?? ''));
}
unset($row);
}
return $rows;
}
protected function get_referral_summary_for_year(int $year) {
global $wpdb;
$table = self::referral_table_name();
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT category, SUM(visits) AS total_visits
FROM {$table}
WHERE stat_date BETWEEN %s AND %s
GROUP BY category",
$start,
$end
), ARRAY_A);
$summary = [
2026-04-18 21:38:56 +00:00
'Direct' => ['visits' => 0, 'unique_visitors' => 0],
'Search' => ['visits' => 0, 'unique_visitors' => 0],
'Social' => ['visits' => 0, 'unique_visitors' => 0],
'Other websites' => ['visits' => 0, 'unique_visitors' => 0],
2026-04-15 19:53:03 +00:00
];
foreach ($rows as $row) {
$category = (string) ($row['category'] ?? '');
$value = (int) ($row['total_visits'] ?? 0);
if ($category === 'direct') {
2026-04-18 21:38:56 +00:00
$summary['Direct']['visits'] = $value;
2026-04-15 19:53:03 +00:00
} elseif ($category === 'search') {
2026-04-18 21:38:56 +00:00
$summary['Search']['visits'] = $value;
2026-04-15 19:53:03 +00:00
} elseif ($category === 'social') {
2026-04-18 21:38:56 +00:00
$summary['Social']['visits'] = $value;
2026-04-15 19:53:03 +00:00
} elseif ($category === 'website') {
2026-04-18 21:38:56 +00:00
$summary['Other websites']['visits'] = $value;
2026-04-15 19:53:03 +00:00
}
}
2026-04-18 21:38:56 +00:00
$unique_table = self::unique_referral_table_name();
$unique_rows = $wpdb->get_results($wpdb->prepare(
"SELECT category, COUNT(*) AS total_unique
FROM {$unique_table}
WHERE period_type = %s
AND period_key = %s
GROUP BY category",
'year',
(string) $year
), ARRAY_A);
if (is_array($unique_rows)) {
foreach ($unique_rows as $row) {
$category = (string) ($row['category'] ?? '');
$unique = (int) ($row['total_unique'] ?? 0);
if ($category === 'direct') {
$summary['Direct']['unique_visitors'] = $unique;
} elseif ($category === 'search') {
$summary['Search']['unique_visitors'] = $unique;
} elseif ($category === 'social') {
$summary['Social']['unique_visitors'] = $unique;
} elseif ($category === 'website') {
$summary['Other websites']['unique_visitors'] = $unique;
}
}
}
return $summary;
}
2026-04-15 19:53:03 +00:00
protected function get_monthly_totals_for_year(int $year) {
global $wpdb;
$table = self::daily_table_name();
2026-04-18 21:38:56 +00:00
$unique_table = self::unique_total_table_name();
2026-04-15 19:53:03 +00:00
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT stat_date, views
FROM {$table}
WHERE stat_date BETWEEN %s AND %s
ORDER BY stat_date ASC",
$start,
$end
), ARRAY_A);
2026-04-18 21:38:56 +00:00
$unique_rows = $wpdb->get_results($wpdb->prepare(
"SELECT period_key, COUNT(*) AS total_unique
FROM {$unique_table}
WHERE period_type = %s AND period_key BETWEEN %s AND %s
GROUP BY period_key
ORDER BY period_key ASC",
'month',
substr($start,0,7),
substr($end,0,7)
), ARRAY_A);
2026-04-15 19:53:03 +00:00
$map = [];
for ($month = 1; $month <= 12; $month++) {
$month_key = sprintf('%04d-%02d', $year, $month);
2026-04-18 21:38:56 +00:00
$map[$month_key] = [
'views' => 0,
'unique_visitors' => 0,
];
2026-04-15 19:53:03 +00:00
}
if (is_array($rows)) {
foreach ($rows as $row) {
$stat_date = isset($row['stat_date']) ? (string) $row['stat_date'] : '';
$month_key = substr($stat_date, 0, 7);
if ($month_key !== '' && isset($map[$month_key])) {
2026-04-18 21:38:56 +00:00
$map[$month_key]['views'] += (int) ($row['views'] ?? 0);
}
}
}
if (is_array($unique_rows)) {
foreach ($unique_rows as $row) {
$month_key = (string) ($row['period_key'] ?? '');
if ($month_key !== '' && isset($map[$month_key])) {
$map[$month_key]['unique_visitors'] = (int) ($row['total_unique'] ?? 0);
2026-04-15 19:53:03 +00:00
}
}
}
$data = [];
2026-04-18 21:38:56 +00:00
foreach ($map as $month_key => $values) {
2026-04-15 19:53:03 +00:00
$data[] = [
'label' => wp_date('M', strtotime($month_key . '-01')),
2026-04-18 21:38:56 +00:00
'views' => (int) ($values['views'] ?? 0),
'unique_visitors' => (int) ($values['unique_visitors'] ?? 0),
2026-04-15 19:53:03 +00:00
];
}
return $data;
}
2026-04-18 21:38:56 +00:00
protected function get_lifetime_yearly_totals() {
global $wpdb;
$daily_table = self::daily_table_name();
$unique_table = self::unique_total_table_name();
$view_rows = $wpdb->get_results(
"SELECT YEAR(stat_date) AS stat_year, SUM(views) AS total_views
FROM {$daily_table}
GROUP BY YEAR(stat_date)
ORDER BY stat_year ASC",
ARRAY_A
);
$unique_rows = $wpdb->get_results($wpdb->prepare(
"SELECT period_key, COUNT(*) AS total_unique
FROM {$unique_table}
WHERE period_type = %s
GROUP BY period_key
ORDER BY period_key ASC",
'year'
), ARRAY_A);
$map = [];
if (is_array($view_rows)) {
foreach ($view_rows as $row) {
$year = (string) ($row['stat_year'] ?? '');
if ($year === '' || $year === '0') {
continue;
}
if (!isset($map[$year])) {
$map[$year] = ['label' => $year, 'views' => 0, 'unique_visitors' => 0];
}
$map[$year]['views'] = (int) ($row['total_views'] ?? 0);
}
}
if (is_array($unique_rows)) {
foreach ($unique_rows as $row) {
$year = preg_replace('/[^0-9]/', '', (string) ($row['period_key'] ?? ''));
if ($year === '') {
continue;
}
if (!isset($map[$year])) {
$map[$year] = ['label' => $year, 'views' => 0, 'unique_visitors' => 0];
}
$map[$year]['unique_visitors'] = (int) ($row['total_unique'] ?? 0);
}
}
if (empty($map)) {
return [];
}
ksort($map, SORT_NATURAL);
return array_values($map);
}
protected function render_lifetime_yearly_chart_markup(array $chart_data, string $chart_id) {
return $this->render_multi_line_chart_markup($chart_data, $chart_id, [
'label_key' => 'label',
'aria_label' => __('Lifetime views and unique visitors by year', 'ansico-stat-plugin'),
'x_label_y' => 246,
'height' => 280,
'width' => 860,
'chart_top' => 18,
'chart_bottom' => 220,
'chart_left' => 42,
'chart_right' => 830,
'max_x_labels' => 12,
]);
}
protected function render_lifetime_weekday_grouped_bar_chart(array $rows, string $chart_id) {
$labels = [];
$views = [];
$unique = [];
$max = 0;
foreach ($rows as $row) {
$label = (string) ($row['label'] ?? '');
$view_value = (int) ($row['views'] ?? 0);
$unique_value = (int) ($row['unique_visitors'] ?? 0);
$labels[] = $label;
$views[] = $view_value;
$unique[] = $unique_value;
$max = max($max, $view_value, $unique_value);
}
$group_count = count($labels);
$max = max($max, 1);
ob_start();
?>
<div class="ansico-stat-grouped-bar-chart" id="<?php echo esc_attr($chart_id); ?>">
<?php if ($group_count === 0) : ?>
<p><?php echo esc_html__('No weekday distribution data has been tracked yet.', 'ansico-stat-plugin'); ?></p>
<?php else : ?>
<div class="ansico-stat-grouped-bar-legend">
<span><span class="ansico-stat-grouped-bar-legend-swatch ansico-stat-grouped-bar-legend-swatch--views"></span><?php echo esc_html__('Views', 'ansico-stat-plugin'); ?></span>
<span><span class="ansico-stat-grouped-bar-legend-swatch ansico-stat-grouped-bar-legend-swatch--unique"></span><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></span>
</div>
<div class="ansico-stat-grouped-bar-grid" role="img" aria-label="<?php echo esc_attr__('Lifetime views and unique visitors by weekday', 'ansico-stat-plugin'); ?>">
<?php foreach ($labels as $index => $label) :
$view_height = $views[$index] > 0 ? max(6, (int) round(($views[$index] / $max) * 180)) : 0;
$unique_height = $unique[$index] > 0 ? max(6, (int) round(($unique[$index] / $max) * 180)) : 0;
?>
<div class="ansico-stat-grouped-bar-group">
<div class="ansico-stat-grouped-bar-track">
<div class="ansico-stat-grouped-bar ansico-stat-grouped-bar--views" style="height:<?php echo esc_attr((string) $view_height); ?>px;" data-label="<?php echo esc_attr($label); ?>" data-series="<?php echo esc_attr__('Views', 'ansico-stat-plugin'); ?>" data-value="<?php echo esc_attr(number_format_i18n($views[$index])); ?>" data-percent="<?php echo esc_attr(number_format_i18n((float) ($rows[$index]['views_percent'] ?? 0), 1)); ?>"></div>
<div class="ansico-stat-grouped-bar ansico-stat-grouped-bar--unique" style="height:<?php echo esc_attr((string) $unique_height); ?>px;" data-label="<?php echo esc_attr($label); ?>" data-series="<?php echo esc_attr__('Unique visitors', 'ansico-stat-plugin'); ?>" data-value="<?php echo esc_attr(number_format_i18n($unique[$index])); ?>" data-percent="<?php echo esc_attr(number_format_i18n((float) ($rows[$index]['unique_percent'] ?? 0), 1)); ?>"></div>
</div>
<div class="ansico-stat-grouped-bar-label"><?php echo esc_html($label); ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="ansico-stat-chart-tooltip" style="display:none; left:-9999px; top:-9999px; opacity:0;" aria-hidden="true"></div>
<style>
#<?php echo esc_html($chart_id); ?> { position:relative; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar-legend { display:flex; gap:16px; align-items:center; flex-wrap:wrap; margin-bottom:12px; font-size:13px; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar-legend-swatch { display:inline-block; width:12px; height:12px; border-radius:3px; margin-right:6px; vertical-align:middle; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar-legend-swatch--views, #<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar--views { background:#2271b1; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar-legend-swatch--unique, #<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar--unique { background:#46b450; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar-grid { display:grid; grid-template-columns:repeat(7, minmax(70px, 1fr)); gap:12px; align-items:end; min-height:240px; padding:8px 0 0; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar-group { display:flex; flex-direction:column; align-items:center; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar-track { height:190px; width:100%; display:flex; align-items:flex-end; justify-content:center; gap:8px; border-bottom:1px solid #dcdcde; padding-bottom:8px; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar { width:24px; border-radius:6px 6px 0 0; cursor:pointer; }
#<?php echo esc_html($chart_id); ?> .ansico-stat-grouped-bar-label { margin-top:8px; font-size:12px; text-align:center; }
</style>
<script>
(function() {
var root = document.getElementById(<?php echo wp_json_encode($chart_id); ?>);
if (!root) return;
var tooltip = root.querySelector('.ansico-stat-chart-tooltip');
var items = root.querySelectorAll('.ansico-stat-grouped-bar');
if (!tooltip || !items.length) return;
function resetTooltip() {
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
tooltip.style.left = '-9999px';
tooltip.style.top = '-9999px';
}
function positionTooltip(item) {
var rootRect = root.getBoundingClientRect();
var itemRect = item.getBoundingClientRect();
var tipRect = tooltip.getBoundingClientRect();
var left = (itemRect.left - rootRect.left) + (itemRect.width / 2) - (tipRect.width / 2);
var top = (itemRect.top - rootRect.top) - tipRect.height - 8;
if (left < 8) left = 8;
if (left + tipRect.width > rootRect.width - 8) left = rootRect.width - tipRect.width - 8;
if (top < 8) top = (itemRect.bottom - rootRect.top) + 8;
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
}
function showTooltip(event) {
var item = event.currentTarget;
tooltip.textContent = item.getAttribute('data-label') + ' — ' + item.getAttribute('data-series') + ': ' + item.getAttribute('data-value') + ' (' + item.getAttribute('data-percent') + '%)';
tooltip.style.display = 'block';
tooltip.style.opacity = '1';
positionTooltip(item);
}
items.forEach(function(item) {
item.addEventListener('mouseenter', showTooltip);
item.addEventListener('mouseleave', resetTooltip);
});
root.addEventListener('mouseleave', resetTooltip);
resetTooltip();
})();
</script>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
protected function render_lifetime_yearly_table(array $rows) {
if (empty($rows)) {
echo '<p>' . esc_html__('No lifetime data has been tracked yet.', 'ansico-stat-plugin') . '</p>';
return;
}
?>
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('Year', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Views', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row) : ?>
<tr>
<td><?php echo esc_html((string) ($row['label'] ?? '')); ?></td>
<td><?php echo esc_html(number_format_i18n((int) ($row['views'] ?? 0))); ?></td>
<td><?php echo esc_html(number_format_i18n((int) ($row['unique_visitors'] ?? 0))); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
}
protected function get_lifetime_weekday_distribution() {
global $wpdb;
$daily_table = self::daily_table_name();
$unique_table = self::unique_total_table_name();
$view_rows = $wpdb->get_results(
"SELECT stat_date, SUM(views) AS total_views
FROM {$daily_table}
GROUP BY stat_date
ORDER BY stat_date ASC",
ARRAY_A
);
$unique_rows = $wpdb->get_results($wpdb->prepare(
"SELECT period_key, COUNT(*) AS total_unique
FROM {$unique_table}
WHERE period_type = %s
GROUP BY period_key
ORDER BY period_key ASC",
'day'
), ARRAY_A);
$labels = [
1 => __('Monday', 'ansico-stat-plugin'),
2 => __('Tuesday', 'ansico-stat-plugin'),
3 => __('Wednesday', 'ansico-stat-plugin'),
4 => __('Thursday', 'ansico-stat-plugin'),
5 => __('Friday', 'ansico-stat-plugin'),
6 => __('Saturday', 'ansico-stat-plugin'),
7 => __('Sunday', 'ansico-stat-plugin'),
];
$map = [];
foreach ($labels as $day_number => $label) {
$map[$day_number] = [
'label' => $label,
'views' => 0,
'unique_visitors' => 0,
];
}
if (is_array($view_rows)) {
foreach ($view_rows as $row) {
$date = (string) ($row['stat_date'] ?? '');
if ($date === '') {
continue;
}
$weekday = (int) wp_date('N', strtotime($date));
if (!isset($map[$weekday])) {
continue;
}
$map[$weekday]['views'] += (int) ($row['total_views'] ?? 0);
}
}
if (is_array($unique_rows)) {
foreach ($unique_rows as $row) {
$date = (string) ($row['period_key'] ?? '');
if ($date === '') {
continue;
}
$weekday = (int) wp_date('N', strtotime($date));
if (!isset($map[$weekday])) {
continue;
}
$map[$weekday]['unique_visitors'] += (int) ($row['total_unique'] ?? 0);
}
}
$total_views = 0;
$total_unique = 0;
foreach ($map as $row) {
$total_views += (int) $row['views'];
$total_unique += (int) $row['unique_visitors'];
}
$rows = [];
foreach ($map as $day_number => $row) {
$views = (int) $row['views'];
$unique = (int) $row['unique_visitors'];
$rows[] = [
'day_number' => $day_number,
'label' => (string) $row['label'],
'views' => $views,
'unique_visitors' => $unique,
'views_percent' => $total_views > 0 ? number_format_i18n(($views / $total_views) * 100, 1) . '%' : '0%',
'unique_percent' => $total_unique > 0 ? number_format_i18n(($unique / $total_unique) * 100, 1) . '%' : '0%',
];
}
return $rows;
}
protected function render_lifetime_weekday_distribution_table(array $rows) {
if (empty($rows)) {
echo '<p>' . esc_html__('No weekday distribution data has been tracked yet.', 'ansico-stat-plugin') . '</p>';
return;
}
?>
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('Weekday', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Views', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Views %', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Unique %', 'ansico-stat-plugin'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row) : ?>
<tr>
<td><?php echo esc_html((string) ($row['label'] ?? '')); ?></td>
<td><?php echo esc_html(number_format_i18n((int) ($row['views'] ?? 0))); ?></td>
<td><?php echo esc_html((string) ($row['views_percent'] ?? '0%')); ?></td>
<td><?php echo esc_html(number_format_i18n((int) ($row['unique_visitors'] ?? 0))); ?></td>
<td><?php echo esc_html((string) ($row['unique_percent'] ?? '0%')); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
}
2026-04-15 19:53:03 +00:00
protected function is_unknown_only_dimension_rows(array $rows) {
if (empty($rows)) {
return true;
}
foreach ($rows as $row) {
$value = strtolower(trim((string) ($row['dimension_value'] ?? '')));
$label = strtolower(trim((string) ($row['dimension_label'] ?? '')));
if (!in_array($value, ['', 'unknown'], true) && !in_array($label, ['', 'unknown'], true)) {
return false;
}
}
return true;
}
protected function format_percent(int $value, int $total) {
if ($total <= 0) {
return '0%';
}
$percent = ($value / $total) * 100;
return number_format_i18n($percent, 1) . '%';
}
2026-04-18 21:38:56 +00:00
protected function get_rows_total(array $rows, string $key) {
$total = 0;
foreach ($rows as $row) {
$total += (int) ($row[$key] ?? 0);
}
return $total;
}
2026-04-15 19:53:03 +00:00
protected function render_referral_summary_table(array $summary) {
2026-04-18 21:38:56 +00:00
$direct = is_array($summary['direct'] ?? null) ? $summary['direct'] : ['visits' => (int) ($summary['direct'] ?? 0), 'unique_visitors' => 0];
$search = is_array($summary['search'] ?? null) ? $summary['search'] : ['visits' => (int) ($summary['search'] ?? 0), 'unique_visitors' => 0];
$social = is_array($summary['social'] ?? null) ? $summary['social'] : ['visits' => (int) ($summary['social'] ?? 0), 'unique_visitors' => 0];
$website = is_array($summary['website'] ?? null) ? $summary['website'] : ['visits' => (int) ($summary['website'] ?? 0), 'unique_visitors' => 0];
$total = (int) ($direct['visits'] ?? 0) + (int) ($search['visits'] ?? 0) + (int) ($social['visits'] ?? 0) + (int) ($website['visits'] ?? 0);
$unique_total = (int) ($direct['unique_visitors'] ?? 0) + (int) ($search['unique_visitors'] ?? 0) + (int) ($social['unique_visitors'] ?? 0) + (int) ($website['unique_visitors'] ?? 0);
2026-04-15 19:53:03 +00:00
?>
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('Referral type', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Visits', 'ansico-stat-plugin'); ?></th>
2026-04-18 21:38:56 +00:00
<th><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Visits %', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Unique %', 'ansico-stat-plugin'); ?></th>
2026-04-15 19:53:03 +00:00
</tr>
</thead>
<tbody>
2026-04-18 21:38:56 +00:00
<tr><td><?php echo esc_html__('Direct visitors', 'ansico-stat-plugin'); ?></td><td><?php echo esc_html(number_format_i18n((int) ($direct['visits'] ?? 0))); ?></td><td><?php echo esc_html(number_format_i18n((int) ($direct['unique_visitors'] ?? 0))); ?></td><td><?php echo esc_html($this->format_percent((int) ($direct['visits'] ?? 0), $total)); ?></td><td><?php echo esc_html($this->format_percent((int) ($direct['unique_visitors'] ?? 0), $unique_total)); ?></td></tr>
<tr><td><?php echo esc_html__('Visitors from search engines', 'ansico-stat-plugin'); ?></td><td><?php echo esc_html(number_format_i18n((int) ($search['visits'] ?? 0))); ?></td><td><?php echo esc_html(number_format_i18n((int) ($search['unique_visitors'] ?? 0))); ?></td><td><?php echo esc_html($this->format_percent((int) ($search['visits'] ?? 0), $total)); ?></td><td><?php echo esc_html($this->format_percent((int) ($search['unique_visitors'] ?? 0), $unique_total)); ?></td></tr>
<tr><td><?php echo esc_html__('Visitors from social media', 'ansico-stat-plugin'); ?></td><td><?php echo esc_html(number_format_i18n((int) ($social['visits'] ?? 0))); ?></td><td><?php echo esc_html(number_format_i18n((int) ($social['unique_visitors'] ?? 0))); ?></td><td><?php echo esc_html($this->format_percent((int) ($social['visits'] ?? 0), $total)); ?></td><td><?php echo esc_html($this->format_percent((int) ($social['unique_visitors'] ?? 0), $unique_total)); ?></td></tr>
<tr><td><?php echo esc_html__('Visitors from other websites', 'ansico-stat-plugin'); ?></td><td><?php echo esc_html(number_format_i18n((int) ($website['visits'] ?? 0))); ?></td><td><?php echo esc_html(number_format_i18n((int) ($website['unique_visitors'] ?? 0))); ?></td><td><?php echo esc_html($this->format_percent((int) ($website['visits'] ?? 0), $total)); ?></td><td><?php echo esc_html($this->format_percent((int) ($website['unique_visitors'] ?? 0), $unique_total)); ?></td></tr>
2026-04-15 19:53:03 +00:00
</tbody>
</table>
<?php
}
2026-04-18 21:38:56 +00:00
protected function maybe_open_scrollable_table_wrapper(int $row_count, int $threshold = 10, string $extra_class = 'ansico-stat-list-table-scroll') {
if ($row_count > $threshold) {
$classes = trim('ansico-table-scroll-wrap ' . $extra_class);
$style = 'max-height:470px; overflow-y:auto; overflow-x:auto; display:block; width:100%;';
echo '<div class="' . esc_attr($classes) . '" style="' . esc_attr($style) . '">';
return true;
}
return false;
}
protected function maybe_close_scrollable_table_wrapper(bool $opened) {
if ($opened) {
echo '</div>';
}
}
2026-04-15 19:53:03 +00:00
protected function render_dimension_table(array $rows, string $value_label, string $views_label = '') {
if ($views_label === '') {
$views_label = __('Views', 'ansico-stat-plugin');
}
if (empty($rows)) {
return;
}
2026-04-18 21:38:56 +00:00
$total = $this->get_rows_total($rows, 'total_views');
$unique_total = $this->get_rows_total($rows, 'total_unique');
$opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows));
2026-04-15 19:53:03 +00:00
?>
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('#', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html($value_label); ?></th>
<th><?php echo esc_html($views_label); ?></th>
2026-04-18 21:38:56 +00:00
<th><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Views %', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Unique %', 'ansico-stat-plugin'); ?></th>
2026-04-15 19:53:03 +00:00
</tr>
</thead>
<tbody>
<?php foreach ($rows as $index => $row) : ?>
2026-04-18 21:38:56 +00:00
<?php $count = (int) ($row['total_views'] ?? 0); $unique = (int) ($row['total_unique'] ?? 0); ?>
2026-04-15 19:53:03 +00:00
<tr>
<td><?php echo esc_html((string) ($index + 1)); ?></td>
<td>
<?php
$label = (string) ($row['dimension_label'] ?? $row['dimension_value'] ?? '');
$value = (string) ($row['dimension_value'] ?? '');
echo esc_html($label);
if (!empty($value) && $label !== $value) {
echo ' <span class="description">(' . esc_html($value) . ')</span>';
}
?>
</td>
<td><?php echo esc_html(number_format_i18n($count)); ?></td>
2026-04-18 21:38:56 +00:00
<td><?php echo esc_html(number_format_i18n($unique)); ?></td>
2026-04-15 19:53:03 +00:00
<td><?php echo esc_html($this->format_percent($count, $total)); ?></td>
2026-04-18 21:38:56 +00:00
<td><?php echo esc_html($this->format_percent($unique, $unique_total)); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php $this->maybe_close_scrollable_table_wrapper($opened_scroll); ?>
<?php
}
protected function render_landing_pages_table(array $rows, string $empty_message = '') {
if ($empty_message === '') {
$empty_message = __('No landing pages tracked for this period yet.', 'ansico-stat-plugin');
}
if (empty($rows)) {
echo '<p>' . esc_html($empty_message) . '</p>';
return;
}
$unique_total = $this->get_rows_total($rows, 'total_unique');
$opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows));
?>
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('#', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Landing page', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Unique %', 'ansico-stat-plugin'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $index => $row) : ?>
<?php $unique = (int) ($row['total_unique'] ?? 0); ?>
<tr>
<td><?php echo esc_html((string) ($index + 1)); ?></td>
<td><?php echo $this->format_landing_page_link((string) ($row['page_url'] ?? ''), (string) ($row['page_label'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
<td><?php echo esc_html(number_format_i18n($unique)); ?></td>
<td><?php echo esc_html($this->format_percent($unique, $unique_total)); ?></td>
2026-04-15 19:53:03 +00:00
</tr>
<?php endforeach; ?>
</tbody>
</table>
2026-04-18 21:38:56 +00:00
<?php $this->maybe_close_scrollable_table_wrapper($opened_scroll); ?>
2026-04-15 19:53:03 +00:00
<?php
}
protected function render_top_404_table(array $rows) {
if (empty($rows)) {
echo '<p>' . esc_html__('No tracked 404 views for this month.', 'ansico-stat-plugin') . '</p>';
return;
}
2026-04-18 21:38:56 +00:00
$opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows));
2026-04-15 19:53:03 +00:00
?>
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('#', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('404 URL', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Views', 'ansico-stat-plugin'); ?></th>
2026-04-18 21:38:56 +00:00
<th><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></th>
2026-04-15 19:53:03 +00:00
</tr>
</thead>
<tbody>
<?php foreach ($rows as $index => $row) : ?>
<tr>
<td><?php echo esc_html((string) ($index + 1)); ?></td>
<td><?php echo $this->format_external_link((string) ($row['page_url'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
<td><?php echo esc_html(number_format_i18n((int) ($row['period_views'] ?? 0))); ?></td>
2026-04-18 21:38:56 +00:00
<td><?php echo esc_html(number_format_i18n((int) ($row['unique_visitors'] ?? 0))); ?></td>
2026-04-15 19:53:03 +00:00
</tr>
<?php endforeach; ?>
</tbody>
</table>
2026-04-18 21:38:56 +00:00
<?php $this->maybe_close_scrollable_table_wrapper($opened_scroll); ?>
2026-04-15 19:53:03 +00:00
<?php
}
protected function render_top_posts_table(array $rows, string $views_label) {
if (empty($rows)) {
echo '<p>' . esc_html__('No views tracked for this period.', 'ansico-stat-plugin') . '</p>';
return;
}
2026-04-18 21:38:56 +00:00
$opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows));
2026-04-15 19:53:03 +00:00
?>
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('#', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Title', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Post type', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html($views_label); ?></th>
2026-04-18 21:38:56 +00:00
<th><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></th>
2026-04-15 19:53:03 +00:00
<th><?php echo esc_html__('Total lifetime views', 'ansico-stat-plugin'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $index => $row) : ?>
<?php
$post_id = (int) $row['post_id'];
$title = get_the_title($post_id) ?: __('(no title)', 'ansico-stat-plugin');
$total_views = (int) get_post_meta($post_id, self::TOTAL_META_KEY, true);
$post_type = get_post_type($post_id);
?>
<tr>
<td><?php echo esc_html((string) ($index + 1)); ?></td>
<td><?php echo $this->format_post_link($post_id, $title); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
<td><?php echo esc_html($post_type ?: '—'); ?></td>
<td><?php echo esc_html(number_format_i18n((int) $row['period_views'])); ?></td>
2026-04-18 21:38:56 +00:00
<td><?php echo esc_html(number_format_i18n((int) ($row['unique_visitors'] ?? 0))); ?></td>
2026-04-15 19:53:03 +00:00
<td><?php echo esc_html(number_format_i18n($total_views)); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
2026-04-18 21:38:56 +00:00
<?php $this->maybe_close_scrollable_table_wrapper($opened_scroll); ?>
2026-04-15 19:53:03 +00:00
<?php
}
2026-04-18 21:38:56 +00:00
protected function format_landing_page_link(string $url, string $label = '') {
$label = trim($label);
if ($label === '') {
$label = $url !== '' ? $url : '—';
}
if ($url === '') {
return esc_html($this->get_truncated_url_text($label, 100));
}
return $this->format_url_display_link($url, $label);
}
2026-04-15 19:53:03 +00:00
protected function render_referral_sources_table(array $rows, string $label) {
echo '<h3>' . esc_html($label) . '</h3>';
if (empty($rows)) {
echo '<p>' . esc_html__('No tracked referral URLs for this category in the selected month.', 'ansico-stat-plugin') . '</p>';
return;
}
?>
2026-04-18 21:38:56 +00:00
<?php $total = $this->get_rows_total($rows, 'total_visits'); $unique_total = $this->get_rows_total($rows, 'total_unique'); ?>
<div class="ansico-table-scroll-wrap ansico-referral-table-scroll">
2026-04-15 19:53:03 +00:00
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('Source host', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Source URL', 'ansico-stat-plugin'); ?></th>
2026-04-18 21:38:56 +00:00
<th><?php echo esc_html__('Landing page', 'ansico-stat-plugin'); ?></th>
2026-04-15 19:53:03 +00:00
<th><?php echo esc_html__('Visits', 'ansico-stat-plugin'); ?></th>
2026-04-18 21:38:56 +00:00
<th><?php echo esc_html__('Unique visitors', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Visits %', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Unique %', 'ansico-stat-plugin'); ?></th>
2026-04-15 19:53:03 +00:00
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row) : ?>
2026-04-18 21:38:56 +00:00
<?php $count = (int) ($row['total_visits'] ?? 0); $unique = (int) ($row['total_unique'] ?? 0); ?>
2026-04-15 19:53:03 +00:00
<tr>
<td><?php echo esc_html($row['source_host'] ?: '—'); ?></td>
<td><?php echo $this->format_external_link((string) $row['source_url']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
2026-04-18 21:38:56 +00:00
<td><?php echo $this->format_landing_page_link((string) ($row['landing_page_url'] ?? ''), (string) ($row['landing_page_label'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
2026-04-15 19:53:03 +00:00
<td><?php echo esc_html(number_format_i18n($count)); ?></td>
2026-04-18 21:38:56 +00:00
<td><?php echo esc_html(number_format_i18n($unique)); ?></td>
2026-04-15 19:53:03 +00:00
<td><?php echo esc_html($this->format_percent($count, $total)); ?></td>
2026-04-18 21:38:56 +00:00
<td><?php echo esc_html($this->format_percent($unique, $unique_total)); ?></td>
2026-04-15 19:53:03 +00:00
</tr>
<?php endforeach; ?>
</tbody>
</table>
2026-04-18 21:38:56 +00:00
</div>
2026-04-15 19:53:03 +00:00
<?php
}
2026-04-18 21:38:56 +00:00
protected function get_google_redirect_uri() {
return admin_url('admin-post.php?action=ansico_stat_google_oauth_callback');
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_auth_scopes() {
return [
'https://www.googleapis.com/auth/webmasters.readonly',
'https://www.googleapis.com/auth/siteverification',
'openid',
'email',
];
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function is_google_connected() {
$settings = $this->get_settings();
return !empty($settings['google_access_token']) || !empty($settings['google_refresh_token']);
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_connection_status_label() {
$settings = $this->get_settings();
if (!$this->is_google_connected()) {
return __('Not connected', 'ansico-stat-plugin');
}
if (!empty($settings['google_token_expires_at']) && (int) $settings['google_token_expires_at'] <= time()) {
return __('Connected, token refresh needed', 'ansico-stat-plugin');
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
return __('Connected', 'ansico-stat-plugin');
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_site_url_prefix() {
return trailingslashit(home_url('/'));
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_url_prefix_property() {
$site_url = $this->get_google_site_url_prefix();
if (!preg_match('#^https?://#i', $site_url)) {
$site_url = 'https://' . ltrim($site_url, '/');
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
return trailingslashit($site_url);
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function get_google_auto_verification_property() {
$selected_property = $this->get_google_selected_property();
if ($selected_property !== '' && !$this->is_google_domain_property($selected_property)) {
return $selected_property;
}
return $this->get_google_url_prefix_property();
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function get_google_selected_property() {
$settings = $this->get_settings();
$property = sanitize_text_field((string) ($settings['google_property'] ?? ''));
return $property !== '' ? $property : $this->get_google_site_url_prefix();
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function is_google_domain_property(string $property = '') {
if ($property === '') {
$property = $this->get_google_selected_property();
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
return strpos($property, 'sc-domain:') === 0;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function get_google_verification_request_details(string $property = '') {
$property = $property !== '' ? $property : $this->get_google_selected_property();
if ($this->is_google_domain_property($property)) {
return [
'identifier' => substr($property, strlen('sc-domain:')),
'site_type' => 'INET_DOMAIN',
'method' => 'DNS_TXT',
];
2026-04-17 21:56:44 +00:00
}
return [
2026-04-18 21:38:56 +00:00
'identifier' => $property,
'site_type' => 'SITE',
'method' => 'META',
2026-04-17 21:56:44 +00:00
];
}
2026-04-18 21:38:56 +00:00
protected function build_google_oauth_authorization_url() {
$settings = $this->get_settings();
if (empty($settings['google_client_id']) || empty($settings['google_client_secret'])) {
return '';
}
$state = wp_generate_password(20, false, false);
set_transient('ansico_stat_google_oauth_state_' . get_current_user_id(), $state, 15 * MINUTE_IN_SECONDS);
2026-04-17 21:56:44 +00:00
$args = [
2026-04-18 21:38:56 +00:00
'client_id' => $settings['google_client_id'],
'redirect_uri' => $this->get_google_redirect_uri(),
'response_type' => 'code',
'scope' => implode(' ', $this->get_google_auth_scopes()),
'access_type' => 'offline',
'prompt' => 'consent',
'include_granted_scopes' => 'true',
'state' => $state,
2026-04-17 21:56:44 +00:00
];
2026-04-18 21:38:56 +00:00
return add_query_arg($args, 'https://accounts.google.com/o/oauth2/v2/auth');
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function update_google_settings(array $changes) {
$settings = $this->get_settings();
$settings = array_merge($settings, $changes);
update_option(self::OPTION_KEY, $settings, false);
return $settings;
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function clear_google_connection_data() {
$this->update_google_settings([
'google_access_token' => '',
'google_refresh_token' => '',
'google_token_expires_at' => 0,
'google_connected_email' => '',
'google_property' => '',
'google_site_verification_token' => '',
'google_last_sync_month' => '',
]);
}
protected function get_google_access_token() {
$settings = $this->get_settings();
if (!empty($settings['google_access_token']) && (!empty($settings['google_token_expires_at']) && (int) $settings['google_token_expires_at'] > time() + 60)) {
return $settings['google_access_token'];
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
if (empty($settings['google_refresh_token']) || empty($settings['google_client_id']) || empty($settings['google_client_secret'])) {
return '';
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
'timeout' => 20,
'body' => [
'client_id' => $settings['google_client_id'],
'client_secret' => $settings['google_client_secret'],
'refresh_token' => $settings['google_refresh_token'],
'grant_type' => 'refresh_token',
2026-04-17 21:56:44 +00:00
],
]);
2026-04-18 21:38:56 +00:00
if (is_wp_error($response)) {
return '';
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = json_decode((string) wp_remote_retrieve_body($response), true);
if ($code < 200 || $code >= 300 || !is_array($body) || empty($body['access_token'])) {
return '';
}
$this->update_google_settings([
'google_access_token' => sanitize_text_field((string) $body['access_token']),
'google_token_expires_at' => time() + max(60, (int) ($body['expires_in'] ?? 3600)),
]);
return sanitize_text_field((string) $body['access_token']);
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function google_api_request($method, $url, $body = null, $access_token = '') {
if ($access_token === '') {
$access_token = $this->get_google_access_token();
}
if ($access_token === '') {
return new WP_Error('ansico_google_missing_token', __('Google access token is missing.', 'ansico-stat-plugin'));
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$args = [
'method' => strtoupper($method),
'timeout' => 20,
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'Accept' => 'application/json',
],
];
if ($body !== null) {
$args['headers']['Content-Type'] = 'application/json';
$args['body'] = wp_json_encode($body);
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
return $response;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$code = (int) wp_remote_retrieve_response_code($response);
$raw_body = (string) wp_remote_retrieve_body($response);
$decoded = $raw_body !== '' ? json_decode($raw_body, true) : [];
if ($code < 200 || $code >= 300) {
$message = __('Google API request failed.', 'ansico-stat-plugin');
if (is_array($decoded) && !empty($decoded['error']['message'])) {
$message = sanitize_text_field((string) $decoded['error']['message']);
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
return new WP_Error('ansico_google_api_error', $message, ['status' => $code, 'body' => $decoded]);
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
return is_array($decoded) ? $decoded : [];
}
protected function get_google_sites() {
$result = $this->google_api_request('GET', 'https://www.googleapis.com/webmasters/v3/sites');
if (is_wp_error($result)) {
return $result;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
return is_array($result['siteEntry'] ?? null) ? $result['siteEntry'] : [];
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_selected_site_permission() {
$site_url = $this->get_google_selected_property();
$sites = $this->get_google_sites();
if (is_wp_error($sites)) {
return '';
}
foreach ($sites as $site) {
if (($site['siteUrl'] ?? '') === $site_url) {
return sanitize_text_field((string) ($site['permissionLevel'] ?? ''));
2026-04-17 21:56:44 +00:00
}
}
2026-04-18 21:38:56 +00:00
return '';
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_api_diagnostic_message($error, string $api_label) {
if (!is_wp_error($error)) {
return '';
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$message = (string) $error->get_error_message();
$lower = strtolower($message);
if (strpos($lower, 'access not configured') !== false || strpos($lower, 'has not been used in project') !== false || strpos($lower, 'api has not been used') !== false) {
return sprintf(__('The %s is probably not enabled in Google Cloud yet.', 'ansico-stat-plugin'), $api_label);
}
if (strpos($lower, 'insufficient authentication scopes') !== false || strpos($lower, 'request had insufficient authentication scopes') !== false) {
return __('Reconnect with Google so the plugin can request the required permissions again.', 'ansico-stat-plugin');
}
if (strpos($lower, 'forbidden') !== false || strpos($lower, 'permission') !== false) {
return __('Google accepted the login, but the connected account does not currently have permission for this action.', 'ansico-stat-plugin');
}
if (strpos($lower, 'invalid client') !== false || strpos($lower, 'deleted_client') !== false) {
return __('The OAuth client ID or secret appears invalid. Recheck the credentials in Google Cloud.', 'ansico-stat-plugin');
}
if (strpos($lower, 'redirect_uri_mismatch') !== false) {
return __('The redirect URI in Google Cloud does not match the redirect URI shown in this plugin.', 'ansico-stat-plugin');
}
return $message !== '' ? $message : __('The Google API returned an unknown error.', 'ansico-stat-plugin');
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function get_google_setup_diagnostics() {
$settings = $this->get_settings();
$diagnostics = [];
$diagnostics[] = [
'label' => __('OAuth client credentials', 'ansico-stat-plugin'),
'status' => (!empty($settings['google_client_id']) && !empty($settings['google_client_secret'])) ? 'success' : 'error',
'message' => (!empty($settings['google_client_id']) && !empty($settings['google_client_secret']))
? __('Client ID and client secret are saved.', 'ansico-stat-plugin')
: __('Add and save both the Google OAuth client ID and client secret first.', 'ansico-stat-plugin'),
];
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
if (!$this->is_google_connected()) {
$diagnostics[] = [
'label' => __('OAuth connection', 'ansico-stat-plugin'),
'status' => 'warning',
'message' => __('Not connected yet. If the Connect button fails before returning here, check the OAuth consent screen and authorized redirect URI in Google Cloud.', 'ansico-stat-plugin'),
];
$diagnostics[] = [
'label' => __('Search Console API', 'ansico-stat-plugin'),
'status' => 'warning',
'message' => __('This can be tested after you connect Google.', 'ansico-stat-plugin'),
];
$diagnostics[] = [
'label' => __('Site Verification API', 'ansico-stat-plugin'),
'status' => 'warning',
'message' => __('This can be tested after you connect Google.', 'ansico-stat-plugin'),
];
return $diagnostics;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$access_token = $this->get_google_access_token();
if ($access_token === '') {
$diagnostics[] = [
'label' => __('OAuth connection', 'ansico-stat-plugin'),
'status' => 'error',
'message' => __('The plugin is marked as connected, but it could not refresh the Google access token. Try reconnecting with Google.', 'ansico-stat-plugin'),
];
return $diagnostics;
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$diagnostics[] = [
'label' => __('OAuth connection', 'ansico-stat-plugin'),
'status' => 'success',
'message' => !empty($settings['google_connected_email'])
? sprintf(__('Connected as %s.', 'ansico-stat-plugin'), $settings['google_connected_email'])
: __('Connected and the access token is working.', 'ansico-stat-plugin'),
];
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$sites_result = $this->google_api_request('GET', 'https://www.googleapis.com/webmasters/v3/sites', null, $access_token);
$diagnostics[] = [
'label' => __('Search Console API', 'ansico-stat-plugin'),
'status' => is_wp_error($sites_result) ? 'error' : 'success',
'message' => is_wp_error($sites_result)
? $this->get_google_api_diagnostic_message($sites_result, __('Search Console API', 'ansico-stat-plugin'))
: __('The plugin can list Search Console properties successfully.', 'ansico-stat-plugin'),
];
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$verification_result = $this->google_api_request('GET', 'https://www.googleapis.com/siteVerification/v1/webResource', null, $access_token);
$diagnostics[] = [
'label' => __('Site Verification API', 'ansico-stat-plugin'),
'status' => is_wp_error($verification_result) ? 'error' : 'success',
'message' => is_wp_error($verification_result)
? $this->get_google_api_diagnostic_message($verification_result, __('Site Verification API', 'ansico-stat-plugin'))
: __('The plugin can reach the Site Verification API successfully.', 'ansico-stat-plugin'),
];
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$property = $this->get_google_selected_property();
$diagnostics[] = [
'label' => __('Selected property', 'ansico-stat-plugin'),
'status' => $property !== '' ? 'success' : 'warning',
'message' => $property !== ''
? sprintf(__('Current property: %s', 'ansico-stat-plugin'), $property)
: __('Choose a Search Console property and save the settings.', 'ansico-stat-plugin'),
];
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
return $diagnostics;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function render_google_setup_diagnostics() {
$diagnostics = $this->get_google_setup_diagnostics();
$status_labels = [
'success' => __('OK', 'ansico-stat-plugin'),
'warning' => __('Check', 'ansico-stat-plugin'),
'error' => __('Problem', 'ansico-stat-plugin'),
];
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
echo '<table class="widefat striped ansico-stat-table" style="margin-top:12px;">';
echo '<thead><tr><th>' . esc_html__('Check', 'ansico-stat-plugin') . '</th><th>' . esc_html__('Status', 'ansico-stat-plugin') . '</th><th>' . esc_html__('Details', 'ansico-stat-plugin') . '</th></tr></thead><tbody>';
foreach ($diagnostics as $row) {
$status = (string) ($row['status'] ?? 'warning');
$label = (string) ($status_labels[$status] ?? $status_labels['warning']);
$color = $status === 'success' ? '#2271b1' : ($status === 'error' ? '#b32d2e' : '#996800');
echo '<tr>';
echo '<td>' . esc_html((string) ($row['label'] ?? '')) . '</td>';
echo '<td><strong style="color:' . esc_attr($color) . ';">' . esc_html($label) . '</strong></td>';
echo '<td>' . esc_html((string) ($row['message'] ?? '')) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_site_verification_status() {
$settings = $this->get_settings();
if (!$this->is_google_connected()) {
return __('Not connected', 'ansico-stat-plugin');
}
$permission = $this->get_google_selected_site_permission();
if ($permission !== '') {
return sprintf(__('Verified in Search Console (%s)', 'ansico-stat-plugin'), $permission);
}
if (!empty($settings['google_site_verification_token'])) {
if ($this->is_google_domain_property()) {
return __('DNS verification token generated, waiting for DNS verification', 'ansico-stat-plugin');
}
return __('Verification token generated, waiting for verification', 'ansico-stat-plugin');
}
return __('Not verified in Search Console yet', 'ansico-stat-plugin');
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function get_google_search_console_help_text() {
return __('Use the Connect with Google button below for a Site Kit-style OAuth flow. First save your Google client ID and client secret, then connect, choose a property, verify ownership, and sync the selected month of search queries.', 'ansico-stat-plugin');
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_connection_help_text() {
if ($this->is_google_connected()) {
return __('Google is already connected. You can reconnect to refresh permissions or disconnect and connect a different Google account.', 'ansico-stat-plugin');
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
return __('After you save the client ID and client secret, click Connect with Google. WordPress will send you to Google, and the access and refresh tokens will be stored automatically when you return.', 'ansico-stat-plugin');
}
public function render_google_site_verification_meta_tag() {
$settings = $this->get_settings();
if (empty($settings['google_site_verification_token']) || is_admin()) {
return;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
echo "
" . '<meta name="google-site-verification" content="' . esc_attr($settings['google_site_verification_token']) . '" />' . "
";
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
public function handle_google_connect() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to connect Google Search Console.', 'ansico-stat-plugin'));
}
check_admin_referer('ansico_stat_google_connect');
$url = $this->build_google_oauth_authorization_url();
if ($url === '') {
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=missing-credentials'));
exit;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
wp_safe_redirect($url);
exit;
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
public function handle_google_disconnect() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to disconnect Google Search Console.', 'ansico-stat-plugin'));
}
check_admin_referer('ansico_stat_google_disconnect');
$this->clear_google_connection_data();
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=disconnected'));
exit;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
public function handle_google_oauth_callback() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to complete Google authentication.', 'ansico-stat-plugin'));
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$state = isset($_GET['state']) ? sanitize_text_field(wp_unslash($_GET['state'])) : '';
$saved_state = get_transient('ansico_stat_google_oauth_state_' . get_current_user_id());
delete_transient('ansico_stat_google_oauth_state_' . get_current_user_id());
if ($state === '' || !$saved_state || !hash_equals((string) $saved_state, $state)) {
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=invalid-state'));
exit;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
if (!empty($_GET['error'])) {
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=oauth-denied'));
exit;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$code = isset($_GET['code']) ? sanitize_text_field(wp_unslash($_GET['code'])) : '';
$settings = $this->get_settings();
if ($code === '' || empty($settings['google_client_id']) || empty($settings['google_client_secret'])) {
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=oauth-failed'));
exit;
}
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
'timeout' => 20,
'body' => [
'code' => $code,
'client_id' => $settings['google_client_id'],
'client_secret' => $settings['google_client_secret'],
'redirect_uri' => $this->get_google_redirect_uri(),
'grant_type' => 'authorization_code',
],
]);
if (is_wp_error($response)) {
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=oauth-failed'));
exit;
}
$body = json_decode((string) wp_remote_retrieve_body($response), true);
if ((int) wp_remote_retrieve_response_code($response) < 200 || (int) wp_remote_retrieve_response_code($response) >= 300 || !is_array($body) || empty($body['access_token'])) {
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=oauth-failed'));
exit;
}
$email = '';
if (!empty($body['id_token'])) {
$parts = explode('.', (string) $body['id_token']);
if (count($parts) === 3) {
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
if (is_array($payload) && !empty($payload['email'])) {
$email = sanitize_email((string) $payload['email']);
}
2026-04-17 21:56:44 +00:00
}
}
2026-04-18 21:38:56 +00:00
$this->update_google_settings([
'google_access_token' => sanitize_text_field((string) $body['access_token']),
'google_refresh_token' => !empty($body['refresh_token']) ? sanitize_text_field((string) $body['refresh_token']) : $settings['google_refresh_token'],
'google_token_expires_at' => time() + max(60, (int) ($body['expires_in'] ?? 3600)),
'google_connected_email' => $email,
]);
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=connected'));
exit;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
public function handle_google_generate_verification_token() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to generate a Google verification token.', 'ansico-stat-plugin'));
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
check_admin_referer('ansico_stat_google_generate_verification_token');
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$verification_property = $this->get_google_auto_verification_property();
$details = $this->get_google_verification_request_details($verification_property);
$result = $this->google_api_request('POST', 'https://www.googleapis.com/siteVerification/v1/token', [
'site' => [
'type' => $details['site_type'],
'identifier' => $details['identifier'],
],
'verificationMethod' => $details['method'],
]);
if (is_wp_error($result) || empty($result['token'])) {
$message = is_wp_error($result) ? $result->get_error_message() : __('Could not generate a Google site verification token.', 'ansico-stat-plugin');
set_transient('ansico_stat_google_notice_' . get_current_user_id(), [
'type' => 'error',
'message' => $message,
], MINUTE_IN_SECONDS * 5);
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=token-failed'));
exit;
}
$this->update_google_settings([
'google_site_verification_token' => sanitize_text_field((string) $result['token']),
'google_site_verification_method' => sanitize_key((string) $details['method']),
]);
set_transient('ansico_stat_google_notice_' . get_current_user_id(), [
'type' => 'success',
'message' => __('Google site verification token generated for the URL-prefix property.', 'ansico-stat-plugin'),
], MINUTE_IN_SECONDS * 5);
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=token-generated'));
exit;
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
public function handle_google_verify_site() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to verify the site in Google Search Console.', 'ansico-stat-plugin'));
}
check_admin_referer('ansico_stat_google_verify_site');
$property = $this->get_google_auto_verification_property();
$details = $this->get_google_verification_request_details($property);
$result = $this->google_api_request('PUT', 'https://www.googleapis.com/webmasters/v3/sites/' . rawurlencode($property), null);
if (is_wp_error($result) && !in_array($result->get_error_code(), ['http_409', 'http_201'], true)) {
wp_safe_redirect(add_query_arg([
'page' => 'ansico-stat-plugin',
'tab' => 'settings',
'google_status' => 'verify-failed',
'google_message' => rawurlencode($result->get_error_message()),
], admin_url('admin.php')));
exit;
}
$result = $this->google_api_request('POST', 'https://www.googleapis.com/siteVerification/v1/webResource?verificationMethod=' . rawurlencode($details['method']), [
'site' => [
'type' => $details['site_type'],
'identifier' => $details['identifier'],
],
]);
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
if (is_wp_error($result)) {
set_transient('ansico_stat_google_notice_' . get_current_user_id(), [
'type' => 'error',
'message' => $result->get_error_message(),
], MINUTE_IN_SECONDS * 5);
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=verify-failed'));
exit;
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
// Try to add the verified property to Search Console.
$this->google_api_request('PUT', 'https://www.googleapis.com/webmasters/v3/sites/' . rawurlencode($property), null);
$this->update_google_settings([
'google_property' => $property,
]);
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
set_transient('ansico_stat_google_notice_' . get_current_user_id(), [
'type' => 'success',
'message' => __('Site verification completed.', 'ansico-stat-plugin'),
], MINUTE_IN_SECONDS * 5);
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=verified'));
exit;
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_google_available_properties() {
$sites = $this->get_google_sites();
if (is_wp_error($sites)) {
return [];
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$properties = [];
foreach ($sites as $site) {
$site_url = sanitize_text_field((string) ($site['siteUrl'] ?? ''));
if ($site_url === '') {
continue;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$properties[] = [
'siteUrl' => $site_url,
'permissionLevel' => sanitize_text_field((string) ($site['permissionLevel'] ?? '')),
];
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
usort($properties, function ($a, $b) {
return strcmp($a['siteUrl'], $b['siteUrl']);
});
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
return $properties;
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function get_month_date_range(string $month_key) {
$month_key = preg_match('/^\d{4}-\d{2}$/', $month_key) ? $month_key : wp_date('Y-m', current_time('timestamp'));
$start = $month_key . '-01';
$end = wp_date('Y-m-t', strtotime($start));
return [$start, $end];
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function sync_google_search_queries_for_month(string $month_key, int $row_limit = 50) {
2026-04-17 21:56:44 +00:00
global $wpdb;
2026-04-18 21:38:56 +00:00
$settings = $this->get_settings();
$property = sanitize_text_field((string) ($settings['google_property'] ?? ''));
if ($property === '') {
return new WP_Error('ansico_google_missing_property', __('No Google Search Console property has been selected yet.', 'ansico-stat-plugin'));
}
[$start_date, $end_date] = $this->get_month_date_range($month_key);
$endpoint = 'https://www.googleapis.com/webmasters/v3/sites/' . rawurlencode($property) . '/searchAnalytics/query';
$result = $this->google_api_request('POST', $endpoint, [
'startDate' => $start_date,
'endDate' => $end_date,
'dimensions' => ['query'],
'rowLimit' => max(1, min(250, $row_limit)),
'dataState' => 'final',
]);
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
if (is_wp_error($result)) {
return $result;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$table = self::search_query_table_name();
$wpdb->delete($table, [
'month_key' => $month_key,
'property_url' => $property,
], ['%s', '%s']);
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : [];
$timestamp = current_time('mysql');
foreach ($rows as $row) {
$query = sanitize_text_field((string) (($row['keys'][0] ?? '')));
if ($query === '') {
continue;
}
$wpdb->replace($table, [
'month_key' => $month_key,
'property_url' => $property,
'query_text' => mb_substr($query, 0, 255),
'clicks' => max(0, (int) round((float) ($row['clicks'] ?? 0))),
'impressions' => max(0, (int) round((float) ($row['impressions'] ?? 0))),
'ctr' => (float) ($row['ctr'] ?? 0),
'position' => (float) ($row['position'] ?? 0),
'updated_at' => $timestamp,
], ['%s', '%s', '%s', '%d', '%d', '%f', '%f', '%s']);
}
$this->update_google_settings([
'google_last_sync_month' => $month_key,
]);
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
return count($rows);
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function get_google_search_queries_for_month(string $month_key, int $limit = 50) {
2026-04-17 21:56:44 +00:00
global $wpdb;
2026-04-18 21:38:56 +00:00
$settings = $this->get_settings();
$property = sanitize_text_field((string) ($settings['google_property'] ?? ''));
if ($property === '') {
return [];
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$table = self::search_query_table_name();
return $wpdb->get_results($wpdb->prepare(
"SELECT query_text, clicks, impressions, ctr, position, updated_at
FROM {$table}
WHERE month_key = %s AND property_url = %s
ORDER BY clicks DESC, impressions DESC, query_text ASC
LIMIT %d",
$month_key,
$property,
max(1, min(250, $limit))
), ARRAY_A) ?: [];
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
protected function render_google_search_queries_table(array $rows, string $empty_message = '') {
if ($empty_message === '') {
$empty_message = __('No Google Search Console query data has been imported for this month yet.', 'ansico-stat-plugin');
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
if (empty($rows)) {
echo '<p>' . esc_html($empty_message) . '</p>';
return;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows));
?>
<table class="widefat striped ansico-stat-table">
<thead>
<tr>
<th><?php echo esc_html__('#', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Search query', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Clicks', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Impressions', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('CTR', 'ansico-stat-plugin'); ?></th>
<th><?php echo esc_html__('Average position', 'ansico-stat-plugin'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $index => $row) : ?>
<tr>
<td><?php echo esc_html((string) ($index + 1)); ?></td>
<td><?php echo esc_html((string) ($row['query_text'] ?? '')); ?></td>
<td><?php echo esc_html(number_format_i18n((int) ($row['clicks'] ?? 0))); ?></td>
<td><?php echo esc_html(number_format_i18n((int) ($row['impressions'] ?? 0))); ?></td>
<td><?php echo esc_html(number_format_i18n(((float) ($row['ctr'] ?? 0)) * 100, 2) . '%'); ?></td>
<td><?php echo esc_html(number_format_i18n((float) ($row['position'] ?? 0), 2)); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php $this->maybe_close_scrollable_table_wrapper($opened_scroll); ?>
<?php
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
public function handle_google_sync_month() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to sync Google Search Console data.', 'ansico-stat-plugin'));
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
check_admin_referer('ansico_stat_google_sync_month');
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
$month = isset($_POST['sync_month']) ? sanitize_text_field(wp_unslash($_POST['sync_month'])) : wp_date('Y-m', current_time('timestamp'));
$result = $this->sync_google_search_queries_for_month($month);
if (is_wp_error($result)) {
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_STATS . '&month=' . rawurlencode($month) . '&google-sync=failed'));
exit;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_STATS . '&month=' . rawurlencode($month) . '&google-sync=success'));
exit;
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
public function render_settings_page() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to access this page.', 'ansico-stat-plugin'));
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$settings = $this->get_settings();
$google_properties = $this->is_google_connected() ? $this->get_google_available_properties() : [];
$google_auth_url = $this->build_google_oauth_authorization_url();
?>
<div class="wrap ansico-stat-admin-page">
<h1><?php echo esc_html__('Ansico Stat Plugin', 'ansico-stat-plugin'); ?></h1>
<p><?php echo esc_html__('Use this page to manage plugin settings and reset all collected view statistics.', 'ansico-stat-plugin'); ?></p>
<p class="description"><?php echo esc_html(sprintf(__('A single browser only counts one view per page within %d minutes. You can change this below.', 'ansico-stat-plugin'), (int) $settings['revisit_minutes'])); ?></p>
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
<?php if (isset($_GET['settings-updated']) && $_GET['settings-updated'] === 'true') : ?>
<div class="notice notice-success is-dismissible"><p><?php echo esc_html__('Settings saved.', 'ansico-stat-plugin'); ?></p></div>
<?php endif; ?>
2026-04-15 19:53:03 +00:00
<?php if (isset($_GET['reset']) && $_GET['reset'] === 'success') : ?>
<div class="notice notice-success is-dismissible"><p><?php echo esc_html__('All tracked view data has been reset.', 'ansico-stat-plugin'); ?></p></div>
<?php endif; ?>
2026-04-18 21:38:56 +00:00
<?php if (isset($_GET['google-status'])) : ?>
<?php $google_status = sanitize_key(wp_unslash($_GET['google-status'])); ?>
<?php if (in_array($google_status, ['connected', 'disconnected', 'token-generated', 'verified'], true)) : ?>
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($google_status === 'connected' ? __('Google Search Console connected.', 'ansico-stat-plugin') : ($google_status === 'disconnected' ? __('Google Search Console disconnected.', 'ansico-stat-plugin') : ($google_status === 'token-generated' ? __('Google site verification token generated.', 'ansico-stat-plugin') : __('Site verification attempt completed.', 'ansico-stat-plugin')))); ?></p></div>
<?php else : ?>
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($google_status === 'missing-credentials' ? __('Please save your Google OAuth client ID and client secret first.', 'ansico-stat-plugin') : ($google_status === 'invalid-state' ? __('Google OAuth state validation failed. Please try connecting again.', 'ansico-stat-plugin') : ($google_status === 'oauth-denied' ? __('Google authorization was denied.', 'ansico-stat-plugin') : ($google_status === 'oauth-failed' ? __('Google OAuth could not be completed.', 'ansico-stat-plugin') : ($google_status === 'token-failed' ? __('Could not generate a Google site verification token. Make sure Site Verification API is enabled in Google Cloud, reconnect Google after scope changes, and use DNS verification for domain properties.', 'ansico-stat-plugin') : ($google_status === 'verify-failed' ? __('Google site verification failed. Make sure the URL-prefix property matches your exact site URL and that the verification meta tag is visible in the page source before retrying.', 'ansico-stat-plugin') : __('Google Search Console action failed.', 'ansico-stat-plugin'))))))); ?></p></div>
<?php endif; ?>
<?php endif; ?>
<?php $google_notice = get_transient('ansico_stat_google_notice_' . get_current_user_id()); if (is_array($google_notice) && !empty($google_notice['message'])) : delete_transient('ansico_stat_google_notice_' . get_current_user_id()); ?>
<div class="notice notice-<?php echo esc_attr(($google_notice['type'] ?? '') === 'success' ? 'success' : 'error'); ?> is-dismissible"><p><?php echo esc_html((string) $google_notice['message']); ?></p></div>
<?php endif; ?>
2026-04-15 19:53:03 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Tracking settings', 'ansico-stat-plugin'); ?></h2>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('ansico_stat_save_settings'); ?>
<input type="hidden" name="action" value="ansico_stat_save_settings" />
2026-04-18 21:38:56 +00:00
<input type="hidden" name="settings_section" value="tracking" />
2026-04-15 19:53:03 +00:00
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row"><?php echo esc_html__('Who should be counted', 'ansico-stat-plugin'); ?></th>
<td>
<fieldset>
<label><input type="radio" name="visitor_counting_mode" value="count_all" <?php checked($settings['visitor_counting_mode'], 'count_all'); ?> /> <?php echo esc_html__('Count everyone, including logged in users and administrators', 'ansico-stat-plugin'); ?></label><br />
<label><input type="radio" name="visitor_counting_mode" value="exclude_admins" <?php checked($settings['visitor_counting_mode'], 'exclude_admins'); ?> /> <?php echo esc_html__('Count logged in users, but exclude administrators', 'ansico-stat-plugin'); ?></label><br />
<label><input type="radio" name="visitor_counting_mode" value="exclude_all_logged_in" <?php checked($settings['visitor_counting_mode'], 'exclude_all_logged_in'); ?> /> <?php echo esc_html__('Exclude all logged in users', 'ansico-stat-plugin'); ?></label>
</fieldset>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Count the same visitor again after', 'ansico-stat-plugin'); ?></th>
<td>
<input type="number" name="revisit_minutes" min="1" max="10080" value="<?php echo esc_attr((int) $settings['revisit_minutes']); ?>" class="small-text" />
<span><?php echo esc_html__('minutes', 'ansico-stat-plugin'); ?></span>
<p class="description"><?php echo esc_html__('This controls how long the plugin waits before the same browser can count as another view for the same page again.', 'ansico-stat-plugin'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Exclude known bots and crawlers', 'ansico-stat-plugin'); ?></th>
<td>
<label>
<input type="checkbox" name="exclude_known_bots" value="1" <?php checked(!empty($settings['exclude_known_bots'])); ?> />
<?php echo esc_html__('Skip tracking for requests that look like bots, crawlers, monitoring tools, or previews based on the user agent and request headers.', 'ansico-stat-plugin'); ?>
</label>
<p class="description"><?php echo esc_html__('Bot detection is heuristic. It blocks many common bots, but no detection method is perfect.', 'ansico-stat-plugin'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Footer label', 'ansico-stat-plugin'); ?></th>
<td>
<input type="text" name="frontend_label" value="<?php echo esc_attr((string) $settings['frontend_label']); ?>" class="regular-text" />
<p class="description"><?php echo esc_html__('This text is shown before the count in the admin-only footer display. Default: Views', 'ansico-stat-plugin'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Track singular content types', 'ansico-stat-plugin'); ?></th>
<td>
<?php
$public_post_types = get_post_types(['public' => true], 'objects');
$excluded_types = ['attachment', 'revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles'];
foreach ($public_post_types as $post_type => $post_type_object) :
if (in_array($post_type, $excluded_types, true)) {
continue;
}
$label = $post_type_object->labels->singular_name ?? $post_type;
?>
<label style="display:block; margin-bottom:6px;">
<input type="checkbox" name="track_post_types[]" value="<?php echo esc_attr($post_type); ?>" <?php checked(in_array($post_type, $settings['track_post_types'], true)); ?> />
<?php echo esc_html($label . ' (' . $post_type . ')'); ?>
</label>
<?php endforeach; ?>
<p class="description"><?php echo esc_html__('Choose which posts, pages, and public custom post types should be tracked.', 'ansico-stat-plugin'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Track other page types', 'ansico-stat-plugin'); ?></th>
<td>
<label style="display:block; margin-bottom:6px;"><input type="checkbox" name="track_front_page" value="1" <?php checked(!empty($settings['track_front_page'])); ?> /> <?php echo esc_html__('Front page', 'ansico-stat-plugin'); ?></label>
<label style="display:block; margin-bottom:6px;"><input type="checkbox" name="track_posts_page" value="1" <?php checked(!empty($settings['track_posts_page'])); ?> /> <?php echo esc_html__('Posts page / blog home', 'ansico-stat-plugin'); ?></label>
<label style="display:block; margin-bottom:6px;"><input type="checkbox" name="track_archives" value="1" <?php checked(!empty($settings['track_archives'])); ?> /> <?php echo esc_html__('Archive pages, including categories, tags, taxonomies, authors, date archives, and post type archives', 'ansico-stat-plugin'); ?></label>
<label style="display:block; margin-bottom:6px;"><input type="checkbox" name="track_search" value="1" <?php checked(!empty($settings['track_search'])); ?> /> <?php echo esc_html__('Search results', 'ansico-stat-plugin'); ?></label>
<label style="display:block; margin-bottom:6px;"><input type="checkbox" name="track_404" value="1" <?php checked(!empty($settings['track_404'])); ?> /> <?php echo esc_html__('404 pages', 'ansico-stat-plugin'); ?></label>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Rows in top lists', 'ansico-stat-plugin'); ?></th>
<td>
<input type="number" name="top_list_rows" min="1" max="100" value="<?php echo esc_attr((int) $settings['top_list_rows']); ?>" class="small-text" />
<p class="description"><?php echo esc_html__('Controls how many rows are shown in top lists such as monthly, yearly, dashboard, and 404 lists.', 'ansico-stat-plugin'); ?></p>
</td>
</tr>
2026-04-18 21:38:56 +00:00
2026-04-15 19:53:03 +00:00
<tr>
2026-04-18 21:38:56 +00:00
<th scope="row"><?php echo esc_html__('IPinfo Lite token (optional)', 'ansico-stat-plugin'); ?></th>
2026-04-15 19:53:03 +00:00
<td>
2026-04-18 21:38:56 +00:00
<input type="text" name="ipinfo_token" value="<?php echo esc_attr((string) $settings['ipinfo_token']); ?>" class="regular-text code" />
<p class="description"><?php echo esc_html__('Adds fallback country detection by IP address when your server or CDN does not provide a country header. Leave blank if Cloudflare or another proxy already sets country codes.', 'ansico-stat-plugin'); ?></p>
2026-04-15 19:53:03 +00:00
</td>
</tr>
</tbody>
</table>
<?php submit_button(__('Save settings', 'ansico-stat-plugin')); ?>
</form>
</div>
2026-04-18 21:38:56 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Google Search Console', 'ansico-stat-plugin'); ?></h2>
<p><?php echo esc_html($this->get_google_search_console_help_text()); ?></p>
<p>
<a class="button button-secondary" href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener noreferrer"><?php echo esc_html__('Open Google Cloud Console', 'ansico-stat-plugin'); ?></a>
<a class="button button-secondary" href="https://console.cloud.google.com/apis/credentials/consent" target="_blank" rel="noopener noreferrer"><?php echo esc_html__('Open OAuth consent screen', 'ansico-stat-plugin'); ?></a>
<a class="button button-secondary" href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer"><?php echo esc_html__('Open API Library', 'ansico-stat-plugin'); ?></a>
</p>
<ol style="margin: 0 0 16px 18px;">
<li><?php echo esc_html__('Open Google Cloud Console and create a Google OAuth web application.', 'ansico-stat-plugin'); ?></li>
<li><?php echo esc_html__('Enable both the Search Console API and the Site Verification API in Google Cloud.', 'ansico-stat-plugin'); ?></li>
<li><?php echo esc_html__('Paste the client ID and client secret below and save this section.', 'ansico-stat-plugin'); ?></li>
<li><?php echo esc_html__('Click Connect with Google and approve access.', 'ansico-stat-plugin'); ?></li>
<li><?php echo esc_html__('Choose a Search Console property and save again.', 'ansico-stat-plugin'); ?></li>
<li><?php echo esc_html__('Generate a verification token if needed, then sync the selected month on the Monthly statistics page.', 'ansico-stat-plugin'); ?></li>
</ol>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('ansico_stat_save_settings'); ?>
<input type="hidden" name="action" value="ansico_stat_save_settings" />
<input type="hidden" name="settings_section" value="google" />
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row"><?php echo esc_html__('Google OAuth client ID', 'ansico-stat-plugin'); ?></th>
<td>
<input type="text" name="google_client_id" value="<?php echo esc_attr((string) $settings['google_client_id']); ?>" class="regular-text code" />
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Google OAuth client secret', 'ansico-stat-plugin'); ?></th>
<td>
<input type="text" name="google_client_secret" value="<?php echo esc_attr((string) $settings['google_client_secret']); ?>" class="regular-text code" />
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Redirect URI', 'ansico-stat-plugin'); ?></th>
<td>
<code><?php echo esc_html($this->get_google_redirect_uri()); ?></code>
<p class="description"><?php echo esc_html__('Copy this redirect URI into your Google Cloud OAuth client configuration.', 'ansico-stat-plugin'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Connection status', 'ansico-stat-plugin'); ?></th>
<td>
<p><strong><?php echo esc_html($this->get_google_connection_status_label()); ?></strong><?php if (!empty($settings['google_connected_email'])) : ?> — <?php echo esc_html($settings['google_connected_email']); ?><?php endif; ?></p>
<p class="description"><?php echo esc_html($this->get_google_connection_help_text()); ?></p>
<p>
<?php if (!$this->is_google_connected()) : ?>
<?php if ($google_auth_url !== '') : ?>
<a class="button button-primary" href="<?php echo esc_url(wp_nonce_url(admin_url('admin-post.php?action=ansico_stat_google_connect'), 'ansico_stat_google_connect')); ?>"><?php echo esc_html__('Connect with Google', 'ansico-stat-plugin'); ?></a>
<?php else : ?>
<span class="description"><?php echo esc_html__('Save your client ID and client secret first. After save, reload this page and the Connect with Google button will open Google automatically.', 'ansico-stat-plugin'); ?></span>
<?php endif; ?>
<?php else : ?>
<a class="button button-primary" href="<?php echo esc_url(wp_nonce_url(admin_url('admin-post.php?action=ansico_stat_google_connect'), 'ansico_stat_google_connect')); ?>"><?php echo esc_html__('Reconnect with Google', 'ansico-stat-plugin'); ?></a>
<a class="button button-secondary" href="<?php echo esc_url(wp_nonce_url(admin_url('admin-post.php?action=ansico_stat_google_disconnect'), 'ansico_stat_google_disconnect')); ?>"><?php echo esc_html__('Disconnect', 'ansico-stat-plugin'); ?></a>
<?php endif; ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Setup diagnostics', 'ansico-stat-plugin'); ?></th>
<td>
<p class="description"><?php echo esc_html__('This checks the most common Google setup problems directly from the plugin, including missing API access and expired permissions.', 'ansico-stat-plugin'); ?></p>
<?php $this->render_google_setup_diagnostics(); ?>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Search Console property', 'ansico-stat-plugin'); ?></th>
<td>
<select name="google_property" class="regular-text">
<option value=""><?php echo esc_html__('Select a property', 'ansico-stat-plugin'); ?></option>
<?php foreach ($google_properties as $property) : ?>
<?php $property_label = $property['siteUrl']; ?>
<?php if (strpos($property['siteUrl'], 'sc-domain:') === 0) : ?>
<?php $property_label .= ' — ' . __('Domain property (sync only)', 'ansico-stat-plugin'); ?>
<?php else : ?>
<?php $property_label .= ' — ' . __('URL-prefix property', 'ansico-stat-plugin'); ?>
<?php endif; ?>
<?php if ($property['permissionLevel'] !== '') : ?>
<?php $property_label .= ' — ' . $property['permissionLevel']; ?>
<?php endif; ?>
<option value="<?php echo esc_attr($property['siteUrl']); ?>" <?php selected($settings['google_property'], $property['siteUrl']); ?>><?php echo esc_html($property_label); ?></option>
<?php endforeach; ?>
</select>
<p class="description"><?php echo esc_html__('For syncing query data you may choose any property, but automatic verification always uses the matching URL-prefix property with a meta tag.', 'ansico-stat-plugin'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Verification status', 'ansico-stat-plugin'); ?></th>
<td>
<p><strong><?php echo esc_html($this->get_google_site_verification_status()); ?></strong></p>
<p class="description"><?php echo esc_html(sprintf(__('Automatic verification uses this URL-prefix property: %s', 'ansico-stat-plugin'), $this->get_google_auto_verification_property())); ?></p>
<?php if (!empty($settings['google_site_verification_token'])) : ?>
<p><code>&lt;meta name=&quot;google-site-verification&quot; content=&quot;<?php echo esc_html($settings['google_site_verification_token']); ?>&quot; /&gt;</code></p>
<?php endif; ?>
<p>
<a class="button button-secondary" href="<?php echo esc_url(wp_nonce_url(admin_url('admin-post.php?action=ansico_stat_google_generate_verification_token'), 'ansico_stat_google_generate_verification_token')); ?>"><?php echo esc_html__('Generate verification token', 'ansico-stat-plugin'); ?></a>
<a class="button button-secondary" href="<?php echo esc_url(wp_nonce_url(admin_url('admin-post.php?action=ansico_stat_google_verify_site'), 'ansico_stat_google_verify_site')); ?>"><?php echo esc_html__('Attempt automatic verification', 'ansico-stat-plugin'); ?></a>
</p>
</td>
</tr>
</tbody>
</table>
<?php submit_button(__('Save Google Search Console settings', 'ansico-stat-plugin')); ?>
</form>
</div>
2026-04-15 19:53:03 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Reset all post views', 'ansico-stat-plugin'); ?></h2>
<p><?php echo esc_html__('This deletes all lifetime counters and all daily, monthly, yearly, and referral statistics for posts, pages, and public custom post types. This action cannot be undone.', 'ansico-stat-plugin'); ?></p>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('<?php echo esc_js(__('Are you sure you want to reset all Ansico Stat data?', 'ansico-stat-plugin')); ?>');">
<?php wp_nonce_field('ansico_stat_reset_all'); ?>
<input type="hidden" name="action" value="ansico_stat_reset_all" />
<?php submit_button(__('Reset all post views and statistics', 'ansico-stat-plugin'), 'delete'); ?>
</form>
</div>
</div>
<?php
}
public function render_admin_page() {
$this->render_monthly_stats_page();
}
public function render_monthly_stats_page() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to access this page.', 'ansico-stat-plugin'));
}
$settings = $this->get_settings();
$top_rows = max(1, min(100, (int) $settings['top_list_rows']));
$chart_data = $this->get_daily_chart_data(60);
2026-04-18 21:38:56 +00:00
foreach ($chart_data as &$chart_row) {
if (!empty($chart_row['date'])) {
$timestamp = strtotime((string) $chart_row['date']);
if ($timestamp) {
$chart_row['label'] = wp_date('j', $timestamp);
$chart_row['tooltip_label'] = wp_date('j. F Y', $timestamp);
}
}
}
unset($chart_row);
2026-04-15 19:53:03 +00:00
$selected_month = $this->get_selected_month();
$monthly_rows = $this->get_monthly_top_posts($selected_month, $top_rows);
$referral_summary = $this->get_referral_summary_for_month($selected_month);
$pie_summary = [
2026-04-18 21:38:56 +00:00
__('Direct', 'ansico-stat-plugin') => (int) ($referral_summary['direct']['visits'] ?? 0),
__('Search engines', 'ansico-stat-plugin') => (int) ($referral_summary['search']['visits'] ?? 0),
__('Social media', 'ansico-stat-plugin') => (int) ($referral_summary['social']['visits'] ?? 0),
__('Other websites', 'ansico-stat-plugin') => (int) ($referral_summary['website']['visits'] ?? 0),
2026-04-15 19:53:03 +00:00
];
2026-04-18 21:38:56 +00:00
$search_referrals = $this->get_referral_sources_for_month($selected_month, 'search');
$social_referrals = $this->get_referral_sources_for_month($selected_month, 'social');
$website_referrals = $this->get_referral_sources_for_month($selected_month, 'website');
2026-04-15 19:53:03 +00:00
$monthly_404_rows = $this->get_top_404_pages_for_month($selected_month, $top_rows);
$monthly_country_rows = $this->get_dimension_rows_for_month($selected_month, 'country', $top_rows);
$monthly_device_rows = $this->get_dimension_rows_for_month($selected_month, 'device', max(3, $top_rows));
$monthly_browser_rows = $this->get_dimension_rows_for_month($selected_month, 'browser', $top_rows);
$monthly_os_rows = $this->get_dimension_rows_for_month($selected_month, 'os', $top_rows);
2026-04-18 21:38:56 +00:00
$monthly_landing_rows = $this->get_monthly_top_landing_pages($selected_month, $top_rows);
$monthly_internal_search_rows = $this->get_dimension_rows_for_month($selected_month, 'internal_search', $top_rows);
$monthly_search_query_rows = $this->get_google_search_queries_for_month($selected_month, max(25, $top_rows));
2026-04-15 19:53:03 +00:00
?>
<div class="wrap ansico-stat-admin-page">
<h1><?php echo esc_html__('Ansico Stat Plugin — Monthly statistics', 'ansico-stat-plugin'); ?></h1>
<p><?php echo esc_html__('This page shows monthly rankings, referral source breakdowns, and visitor device details.', 'ansico-stat-plugin'); ?></p>
2026-04-18 21:38:56 +00:00
<?php if (isset($_GET['google-sync'])) : ?>
<?php $google_sync_status = sanitize_key(wp_unslash($_GET['google-sync'])); ?>
<?php if ($google_sync_status === 'success') : ?>
<div class="notice notice-success is-dismissible"><p><?php echo esc_html__('Google Search Console data synced for the selected month.', 'ansico-stat-plugin'); ?></p></div>
<?php elseif ($google_sync_status === 'failed') : ?>
<div class="notice notice-error is-dismissible"><p><?php echo esc_html__('Google Search Console sync failed. Check the Google settings and property selection.', 'ansico-stat-plugin'); ?></p></div>
<?php endif; ?>
<?php endif; ?>
2026-04-15 19:53:03 +00:00
<div class="ansico-stat-card">
2026-04-18 21:38:56 +00:00
<h2><?php echo esc_html__('Daily views and unique visitors (last 60 days)', 'ansico-stat-plugin'); ?></h2>
<?php echo $this->render_multi_line_chart_markup($chart_data, 'ansico-stat-admin-chart', ['aria_label' => __('Daily views and unique visitors (last 60 days)', 'ansico-stat-plugin')]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
2026-04-15 19:53:03 +00:00
</div>
<div class="ansico-stat-card">
<div class="ansico-stat-card-header">
<h2><?php echo esc_html(sprintf(__('Monthly top %d views', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<a class="button button-secondary" href="<?php echo esc_url(wp_nonce_url(admin_url('admin-post.php?action=ansico_stat_export_csv'), 'ansico_stat_export_csv')); ?>"><?php echo esc_html__('Export CSV', 'ansico-stat-plugin'); ?></a>
</div>
<?php $this->render_month_navigation($selected_month); ?>
<?php $this->render_top_posts_table($monthly_rows, __('Monthly views', 'ansico-stat-plugin')); ?>
</div>
2026-04-18 21:38:56 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Monthly top %d landing pages', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_month_navigation($selected_month); ?>
<?php $this->render_landing_pages_table($monthly_landing_rows, __('No landing pages tracked for this month yet.', 'ansico-stat-plugin')); ?>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Monthly internal search queries (top %d)', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_month_navigation($selected_month); ?>
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($monthly_internal_search_rows, __('Search query', 'ansico-stat-plugin')); ?></div>
<div><?php echo $this->render_pie_chart_markup($this->get_top_5_plus_others_segments($monthly_internal_search_rows), 'ansico-monthly-internal-search-pie', __('Monthly internal search queries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
</div>
</div>
<div class="ansico-stat-card">
<div class="ansico-stat-card-header">
<h2><?php echo esc_html__('Monthly Google search queries', 'ansico-stat-plugin'); ?></h2>
<?php if ($this->is_google_connected() && !empty($settings['google_property'])) : ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin:0;">
<?php wp_nonce_field('ansico_stat_google_sync_month'); ?>
<input type="hidden" name="action" value="ansico_stat_google_sync_month" />
<input type="hidden" name="sync_month" value="<?php echo esc_attr($selected_month); ?>" />
<?php submit_button(__('Sync selected month from Google', 'ansico-stat-plugin'), 'secondary', 'submit', false); ?>
</form>
<?php endif; ?>
</div>
<?php $this->render_month_navigation($selected_month); ?>
<?php if (!$this->is_google_connected()) : ?>
<p><?php echo esc_html__('Connect Google Search Console under Settings to import monthly search queries.', 'ansico-stat-plugin'); ?></p>
<?php elseif (empty($settings['google_property'])) : ?>
<p><?php echo esc_html__('Choose a Search Console property under Settings before syncing search queries.', 'ansico-stat-plugin'); ?></p>
<?php else : ?>
<?php if (!empty($settings['google_last_sync_month'])) : ?>
<p class="description"><?php echo esc_html(sprintf(__('Last synced month in this plugin: %s', 'ansico-stat-plugin'), $settings['google_last_sync_month'])); ?></p>
<?php endif; ?>
<?php $this->render_google_search_queries_table($monthly_search_query_rows, __('No Google Search Console query data has been synced for this month yet.', 'ansico-stat-plugin')); ?>
<?php endif; ?>
</div>
2026-04-15 19:53:03 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Monthly top %d 404 visitors', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_month_navigation($selected_month); ?>
<?php $this->render_top_404_table($monthly_404_rows); ?>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Monthly referred visitors', 'ansico-stat-plugin'); ?></h2>
<?php $this->render_month_navigation($selected_month); ?>
<div class="ansico-stat-two-column">
<div>
<?php $this->render_referral_summary_table($referral_summary); ?>
</div>
<div>
<?php echo $this->render_pie_chart_markup($pie_summary, 'ansico-monthly-referral-pie', __('Monthly referred visitors', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
<div style="margin-top:1.5rem; display:grid; gap:1.5rem;">
<div><?php $this->render_referral_sources_table($search_referrals, __('Search engine referral URLs', 'ansico-stat-plugin')); ?></div>
<div><?php $this->render_referral_sources_table($social_referrals, __('Social media referral URLs', 'ansico-stat-plugin')); ?></div>
<div><?php $this->render_referral_sources_table($website_referrals, __('Other website referral URLs', 'ansico-stat-plugin')); ?></div>
</div>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Monthly countries (top %d)', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_month_navigation($selected_month); ?>
2026-04-18 21:38:56 +00:00
<p class="description"><?php echo esc_html__('Country detection uses server/CDN country headers when available. You can also add an IPinfo Lite token in Settings to resolve countries from visitor IP addresses when no country header is present.', 'ansico-stat-plugin'); ?></p>
<?php if ($this->is_unknown_only_dimension_rows($monthly_country_rows)) : ?>
<p><?php echo esc_html__('No country statistics are available yet for this month. New visits after country detection is enabled will appear here.', 'ansico-stat-plugin'); ?></p>
<?php else : ?>
2026-04-15 19:53:03 +00:00
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($monthly_country_rows, __('Country', 'ansico-stat-plugin')); ?></div>
2026-04-18 21:38:56 +00:00
<div><?php echo $this->render_pie_chart_markup($this->get_top_5_plus_others_segments($monthly_country_rows), 'ansico-monthly-country-pie', __('Monthly countries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
2026-04-15 19:53:03 +00:00
</div>
2026-04-18 21:38:56 +00:00
<?php endif; ?>
2026-04-15 19:53:03 +00:00
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Monthly device types', 'ansico-stat-plugin'); ?></h2>
<?php $this->render_month_navigation($selected_month); ?>
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($monthly_device_rows, __('Device type', 'ansico-stat-plugin')); ?></div>
<div><?php echo $this->render_pie_chart_markup(array_column($monthly_device_rows, 'total_views', 'dimension_value'), 'ansico-monthly-device-pie', __('Monthly device types', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
</div>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Monthly browsers (top %d)', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_month_navigation($selected_month); ?>
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($monthly_browser_rows, __('Browser', 'ansico-stat-plugin')); ?></div>
2026-04-18 21:38:56 +00:00
<div><?php echo $this->render_pie_chart_markup($this->get_top_5_plus_others_segments($monthly_browser_rows), 'ansico-monthly-browser-pie', __('Monthly browsers', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
2026-04-15 19:53:03 +00:00
</div>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Monthly operating systems (top %d)', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_month_navigation($selected_month); ?>
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($monthly_os_rows, __('Operating system', 'ansico-stat-plugin')); ?></div>
2026-04-18 21:38:56 +00:00
<div><?php echo $this->render_pie_chart_markup($this->get_top_5_plus_others_segments($monthly_os_rows), 'ansico-monthly-os-pie', __('Monthly operating systems', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
2026-04-15 19:53:03 +00:00
</div>
</div>
</div>
<?php
}
public function render_yearly_stats_page() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to access this page.', 'ansico-stat-plugin'));
}
$settings = $this->get_settings();
$top_rows = max(1, min(100, (int) $settings['top_list_rows']));
$selected_year = $this->get_selected_year();
$yearly_rows = $this->get_yearly_top_posts($selected_year, $top_rows);
$yearly_country_rows = $this->get_dimension_rows_for_year($selected_year, 'country', $top_rows);
$yearly_device_rows = $this->get_dimension_rows_for_year($selected_year, 'device', max(3, $top_rows));
$yearly_browser_rows = $this->get_dimension_rows_for_year($selected_year, 'browser', $top_rows);
$yearly_os_rows = $this->get_dimension_rows_for_year($selected_year, 'os', $top_rows);
2026-04-18 21:38:56 +00:00
$yearly_internal_search_rows = $this->get_dimension_rows_for_year($selected_year, 'internal_search', $top_rows);
2026-04-15 19:53:03 +00:00
$yearly_referral_summary = $this->get_referral_summary_for_year($selected_year);
$yearly_month_chart = $this->get_monthly_totals_for_year($selected_year);
2026-04-18 21:38:56 +00:00
$yearly_landing_rows = $this->get_yearly_top_landing_pages($selected_year, $top_rows);
2026-04-15 19:53:03 +00:00
?>
<div class="wrap ansico-stat-admin-page">
<h1><?php echo esc_html__('Ansico Stat Plugin — Yearly statistics', 'ansico-stat-plugin'); ?></h1>
<p><?php echo esc_html__('This page shows yearly rankings and yearly visitor technology and country breakdowns.', 'ansico-stat-plugin'); ?></p>
<div class="ansico-stat-card">
2026-04-18 21:38:56 +00:00
<h2><?php echo esc_html__('Yearly views and unique visitors by month', 'ansico-stat-plugin'); ?></h2>
2026-04-15 19:53:03 +00:00
<?php $this->render_year_navigation($selected_year); ?>
2026-04-18 21:38:56 +00:00
<?php echo $this->render_multi_line_chart_markup($yearly_month_chart, 'ansico-yearly-month-chart', ['aria_label' => __('Yearly views and unique visitors by month', 'ansico-stat-plugin'), 'max_x_labels' => 12]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
2026-04-15 19:53:03 +00:00
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Yearly top %d views', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_year_navigation($selected_year); ?>
<?php $this->render_top_posts_table($yearly_rows, __('Yearly views', 'ansico-stat-plugin')); ?>
</div>
2026-04-18 21:38:56 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Yearly top %d landing pages', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_year_navigation($selected_year); ?>
<?php $this->render_landing_pages_table($yearly_landing_rows, __('No landing pages tracked for this year yet.', 'ansico-stat-plugin')); ?>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Yearly internal search queries (top %d)', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_year_navigation($selected_year); ?>
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($yearly_internal_search_rows, __('Search query', 'ansico-stat-plugin')); ?></div>
<div><?php echo $this->render_pie_chart_markup($this->get_top_5_plus_others_segments($yearly_internal_search_rows), 'ansico-yearly-internal-search-pie', __('Yearly internal search queries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
</div>
</div>
2026-04-15 19:53:03 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Yearly referred visitors', 'ansico-stat-plugin'); ?></h2>
<?php $this->render_year_navigation($selected_year); ?>
<div class="ansico-stat-two-column">
<div>
<?php $this->render_referral_summary_table([
2026-04-18 21:38:56 +00:00
'direct' => [
'visits' => (int) ($yearly_referral_summary['Direct']['visits'] ?? 0),
'unique_visitors' => (int) ($yearly_referral_summary['Direct']['unique_visitors'] ?? 0),
],
'search' => [
'visits' => (int) ($yearly_referral_summary['Search']['visits'] ?? 0),
'unique_visitors' => (int) ($yearly_referral_summary['Search']['unique_visitors'] ?? 0),
],
'social' => [
'visits' => (int) ($yearly_referral_summary['Social']['visits'] ?? 0),
'unique_visitors' => (int) ($yearly_referral_summary['Social']['unique_visitors'] ?? 0),
],
'website' => [
'visits' => (int) ($yearly_referral_summary['Other websites']['visits'] ?? 0),
'unique_visitors' => (int) ($yearly_referral_summary['Other websites']['unique_visitors'] ?? 0),
],
2026-04-15 19:53:03 +00:00
]); ?>
</div>
2026-04-18 21:38:56 +00:00
<div><?php echo $this->render_pie_chart_markup([
__('Direct', 'ansico-stat-plugin') => (int) ($yearly_referral_summary['Direct']['visits'] ?? 0),
__('Search engines', 'ansico-stat-plugin') => (int) ($yearly_referral_summary['Search']['visits'] ?? 0),
__('Social media', 'ansico-stat-plugin') => (int) ($yearly_referral_summary['Social']['visits'] ?? 0),
__('Other websites', 'ansico-stat-plugin') => (int) ($yearly_referral_summary['Other websites']['visits'] ?? 0),
], 'ansico-yearly-referral-pie', __('Yearly referred visitors', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
2026-04-15 19:53:03 +00:00
</div>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Yearly countries (top %d)', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_year_navigation($selected_year); ?>
2026-04-18 21:38:56 +00:00
<p class="description"><?php echo esc_html__('Country detection uses server/CDN country headers when available. You can also add an IPinfo Lite token in Settings to resolve countries from visitor IP addresses when no country header is present.', 'ansico-stat-plugin'); ?></p>
<?php if ($this->is_unknown_only_dimension_rows($yearly_country_rows)) : ?>
<p><?php echo esc_html__('No country statistics are available yet for this year. New visits after country detection is enabled will appear here.', 'ansico-stat-plugin'); ?></p>
<?php else : ?>
2026-04-15 19:53:03 +00:00
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($yearly_country_rows, __('Country', 'ansico-stat-plugin')); ?></div>
2026-04-18 21:38:56 +00:00
<div><?php echo $this->render_pie_chart_markup($this->get_top_5_plus_others_segments($yearly_country_rows), 'ansico-yearly-country-pie', __('Yearly countries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
2026-04-15 19:53:03 +00:00
</div>
2026-04-18 21:38:56 +00:00
<?php endif; ?>
2026-04-15 19:53:03 +00:00
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Yearly device types', 'ansico-stat-plugin'); ?></h2>
<?php $this->render_year_navigation($selected_year); ?>
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($yearly_device_rows, __('Device type', 'ansico-stat-plugin')); ?></div>
<div><?php echo $this->render_pie_chart_markup(array_column($yearly_device_rows, 'total_views', 'dimension_value'), 'ansico-yearly-device-pie', __('Yearly device types', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
</div>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Yearly browsers (top %d)', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_year_navigation($selected_year); ?>
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($yearly_browser_rows, __('Browser', 'ansico-stat-plugin')); ?></div>
2026-04-18 21:38:56 +00:00
<div><?php echo $this->render_pie_chart_markup($this->get_top_5_plus_others_segments($yearly_browser_rows), 'ansico-yearly-browser-pie', __('Yearly browsers', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
2026-04-15 19:53:03 +00:00
</div>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html(sprintf(__('Yearly operating systems (top %d)', 'ansico-stat-plugin'), (int) $top_rows)); ?></h2>
<?php $this->render_year_navigation($selected_year); ?>
<div class="ansico-stat-two-column">
<div><?php $this->render_dimension_table($yearly_os_rows, __('Operating system', 'ansico-stat-plugin')); ?></div>
2026-04-18 21:38:56 +00:00
<div><?php echo $this->render_pie_chart_markup($this->get_top_5_plus_others_segments($yearly_os_rows), 'ansico-yearly-os-pie', __('Yearly operating systems', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
2026-04-15 19:53:03 +00:00
</div>
</div>
</div>
<?php
}
2026-04-18 21:38:56 +00:00
public function render_lifetime_stats_page() {
2026-04-17 21:56:44 +00:00
if (!current_user_can('manage_options')) {
2026-04-18 21:38:56 +00:00
wp_die(esc_html__('You do not have permission to access this page.', 'ansico-stat-plugin'));
2026-04-17 21:56:44 +00:00
}
2026-04-18 21:38:56 +00:00
$lifetime_chart = $this->get_lifetime_yearly_totals();
$weekday_rows = $this->get_lifetime_weekday_distribution();
?>
<div class="wrap ansico-stat-admin-page">
<h1><?php echo esc_html__('Ansico Stat Plugin — Lifetime statistics', 'ansico-stat-plugin'); ?></h1>
<p><?php echo esc_html__('This page shows the overall year-by-year development in views and unique visitors.', 'ansico-stat-plugin'); ?></p>
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Lifetime views and unique visitors by year', 'ansico-stat-plugin'); ?></h2>
<?php echo $this->render_lifetime_yearly_chart_markup($lifetime_chart, 'ansico-lifetime-yearly-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Lifetime totals by year', 'ansico-stat-plugin'); ?></h2>
<?php $this->render_lifetime_yearly_table($lifetime_chart); ?>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Views and unique visitors by weekday', 'ansico-stat-plugin'); ?></h2>
<p><?php echo esc_html__('The chart shows the lifetime distribution from Monday to Sunday, including percentages for both series.', 'ansico-stat-plugin'); ?></p>
<?php echo $this->render_lifetime_weekday_grouped_bar_chart($weekday_rows, 'ansico-lifetime-weekday-grouped-bar'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<div class="ansico-stat-card">
<h2><?php echo esc_html__('Weekday totals and percentages', 'ansico-stat-plugin'); ?></h2>
<?php $this->render_lifetime_weekday_distribution_table($weekday_rows); ?>
</div>
</div>
<?php
}
2026-04-17 21:56:44 +00:00
2026-04-18 21:38:56 +00:00
protected function normalize_target_url(string $url) {
$url = trim($url);
if ($url === '') {
return '';
}
if (!preg_match('#^https?://#i', $url)) {
$url = home_url('/' . ltrim($url, '/'));
}
$parts = wp_parse_url($url);
if (empty($parts['scheme']) || empty($parts['host'])) {
return '';
}
$scheme = strtolower((string) $parts['scheme']);
$host = strtolower((string) $parts['host']);
$path = isset($parts['path']) ? (string) $parts['path'] : '/';
$normalized = $scheme . '://' . $host . ($path !== '' ? $path : '/');
$normalized = rtrim($normalized, '/');
if ($normalized === $scheme . '://' . $host) {
$normalized .= '/';
}
if (!empty($parts['query'])) {
$normalized .= '?' . $parts['query'];
}
return $normalized;
}
protected function get_requested_single_stats_url() {
$requested = isset($_GET['target_url']) ? sanitize_text_field(wp_unslash($_GET['target_url'])) : '';
return $requested !== '' ? $requested : home_url('/');
}
protected function sanitize_stats_date(string $date) {
$date = trim($date);
if ($date === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return '';
}
$timestamp = strtotime($date . ' 00:00:00');
if (!$timestamp) {
return '';
}
return gmdate('Y-m-d', $timestamp) === $date ? $date : '';
}
protected function get_single_stats_date_range() {
$default_end = current_time('Y-m-d');
$default_start = $this->date_days_ago(29);
$start = isset($_GET['start_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['start_date'])) : '';
$end = isset($_GET['end_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['end_date'])) : '';
if ($start === '') {
$start = $default_start;
}
if ($end === '') {
$end = $default_end;
}
if (strtotime($start) > strtotime($end)) {
[$start, $end] = [$end, $start];
}
return [
'start' => $start,
'end' => $end,
'days' => max(1, (int) floor((strtotime($end) - strtotime($start)) / DAY_IN_SECONDS) + 1),
];
}
protected function build_single_stats_admin_url(string $target_url, string $start_date = '', string $end_date = '') {
$args = [
'page' => self::MENU_SLUG_SINGLE,
'target_url' => $target_url !== '' ? $target_url : home_url('/'),
];
if ($start_date !== '') {
$args['start_date'] = $start_date;
}
if ($end_date !== '') {
$args['end_date'] = $end_date;
}
return add_query_arg($args, admin_url('admin.php'));
}
public function register_frontend_admin_bar_link($wp_admin_bar) {
if (is_admin() || !is_admin_bar_showing() || !current_user_can('manage_options') || !$this->is_trackable_request()) {
return;
}
$page_context = $this->get_current_page_context();
if (empty($page_context['url'])) {
return;
}
$target_url = $this->normalize_target_url((string) $page_context['url']);
if ($target_url === '') {
return;
}
$wp_admin_bar->add_node([
'id' => 'ansico-stat-single-page',
'parent' => 'top-secondary',
'title' => '<span class="ab-icon dashicons dashicons-chart-bar" style="font:normal 20px/1 dashicons;top:2px;"></span><span class="ab-label">' . esc_html__('Page stats', 'ansico-stat-plugin') . '</span>',
'href' => esc_url($this->build_single_stats_admin_url($target_url)),
'meta' => [
'class' => 'ansico-stat-admin-bar-link',
'title' => esc_attr__('Open statistics for this page', 'ansico-stat-plugin'),
],
]);
}
protected function resolve_single_stats_target(string $url) {
global $wpdb;
$normalized_url = $this->normalize_target_url($url);
if ($normalized_url === '') {
return ['success' => false, 'message' => __('Please enter a valid URL.', 'ansico-stat-plugin')];
}
$site_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
$target_host = strtolower((string) wp_parse_url($normalized_url, PHP_URL_HOST));
if ($site_host === '' || $target_host === '' || preg_replace('/^www\\./', '', $site_host) !== preg_replace('/^www\\./', '', $target_host)) {
return ['success' => false, 'message' => __('The URL must belong to this WordPress site.', 'ansico-stat-plugin')];
}
$post_id = url_to_postid($normalized_url);
if ($post_id > 0) {
$post = get_post($post_id);
if ($this->is_trackable_post($post)) {
return [
'success' => true,
'target' => [
'kind' => 'singular',
'post_id' => (int) $post_id,
'page_key' => 'post:' . (int) $post_id,
'label' => get_the_title($post_id) ?: __('(no title)', 'ansico-stat-plugin'),
'url' => get_permalink($post_id) ?: $normalized_url,
],
];
}
}
$front_url = $this->normalize_target_url(home_url('/'));
if ($normalized_url === $front_url) {
return [
'success' => true,
'target' => [
'kind' => 'archive',
'page_key' => 'front_page',
'label' => __('Front page', 'ansico-stat-plugin'),
'url' => home_url('/'),
],
];
}
$table = self::page_daily_table_name();
$variants = array_values(array_unique(array_filter([$normalized_url, untrailingslashit($normalized_url), trailingslashit($normalized_url)])));
if (!empty($variants)) {
$placeholders = implode(',', array_fill(0, count($variants), '%s'));
$row = $wpdb->get_row($wpdb->prepare("SELECT page_key, page_label, page_url FROM {$table} WHERE page_url IN ({$placeholders}) ORDER BY stat_date DESC LIMIT 1", $variants), ARRAY_A);
if (is_array($row) && !empty($row['page_key'])) {
return [
'success' => true,
'target' => [
'kind' => 'archive',
'page_key' => (string) $row['page_key'],
'label' => sanitize_text_field((string) ($row['page_label'] ?? $normalized_url)),
'url' => !empty($row['page_url']) ? esc_url_raw((string) $row['page_url']) : $normalized_url,
],
];
}
}
return ['success' => false, 'message' => __('No tracked page or post was found for that URL yet.', 'ansico-stat-plugin')];
}
protected function get_target_lifetime_views(array $target) {
global $wpdb;
if (($target['kind'] ?? '') === 'singular') {
return (int) get_post_meta((int) $target['post_id'], self::TOTAL_META_KEY, true);
}
$table = self::page_daily_table_name();
return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(views) FROM {$table} WHERE page_key = %s", (string) $target['page_key']));
}
protected function get_target_lifetime_unique_visitors(array $target) {
global $wpdb;
$table = self::unique_page_table_name();
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT visitor_hash) FROM {$table} WHERE page_key = %s", (string) $target['page_key']));
}
protected function get_target_period_views(array $target, string $start, string $end) {
global $wpdb;
if (($target['kind'] ?? '') === 'singular') {
$table = self::post_daily_table_name();
return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(views) FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s", (int) $target['post_id'], $start, $end));
}
$table = self::page_daily_table_name();
return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(views) FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s", (string) $target['page_key'], $start, $end));
}
protected function get_target_period_unique_visitors(array $target, string $start, string $end) {
global $wpdb;
if (($target['kind'] ?? '') === 'singular') {
$table = self::post_daily_table_name();
return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(unique_visitors) FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s", (int) $target['post_id'], $start, $end));
}
$table = self::page_daily_table_name();
return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(unique_visitors) FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s", (string) $target['page_key'], $start, $end));
}
protected function get_target_daily_combined_chart_data_between(array $target, string $start, string $end) {
global $wpdb;
if (($target['kind'] ?? '') === 'singular') {
$table = self::post_daily_table_name();
$rows = $wpdb->get_results($wpdb->prepare("SELECT stat_date, views, unique_visitors FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", (int) $target['post_id'], $start, $end), ARRAY_A);
} else {
$table = self::page_daily_table_name();
$rows = $wpdb->get_results($wpdb->prepare("SELECT stat_date, views, unique_visitors FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", (string) $target['page_key'], $start, $end), ARRAY_A);
}
$indexed = [];
foreach ((array) $rows as $row) {
$indexed[(string) $row['stat_date']] = [
'views' => (int) ($row['views'] ?? 0),
'unique_visitors' => (int) ($row['unique_visitors'] ?? 0),
];
}
$data = [];
$cursor = strtotime($start);
$end_ts = strtotime($end);
while ($cursor <= $end_ts) {
$date = gmdate('Y-m-d', $cursor);
$data[] = [
'date' => $date,
'label' => wp_date('j M', $cursor),
'tooltip_label' => wp_date(get_option('date_format'), $cursor),
'views' => (int) ($indexed[$date]['views'] ?? 0),
'unique_visitors' => (int) ($indexed[$date]['unique_visitors'] ?? 0),
];
$cursor = strtotime('+1 day', $cursor);
}
return $data;
}
protected function get_target_monthly_combined_chart_data_between(array $target, string $start, string $end) {
global $wpdb;
if (($target['kind'] ?? '') === 'singular') {
$table = self::post_daily_table_name();
$rows = $wpdb->get_results($wpdb->prepare("SELECT DATE_FORMAT(stat_date, '%%Y-%%m') AS month_key, SUM(views) AS total_views, SUM(unique_visitors) AS total_unique FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s GROUP BY month_key ORDER BY month_key ASC", (int) $target['post_id'], $start, $end), ARRAY_A);
} else {
$table = self::page_daily_table_name();
$rows = $wpdb->get_results($wpdb->prepare("SELECT DATE_FORMAT(stat_date, '%%Y-%%m') AS month_key, SUM(views) AS total_views, SUM(unique_visitors) AS total_unique FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s GROUP BY month_key ORDER BY month_key ASC", (string) $target['page_key'], $start, $end), ARRAY_A);
}
$indexed = [];
foreach ((array) $rows as $row) {
$indexed[(string) $row['month_key']] = [
'views' => (int) ($row['total_views'] ?? 0),
'unique_visitors' => (int) ($row['total_unique'] ?? 0),
];
}
$data = [];
$cursor = strtotime(gmdate('Y-m-01', strtotime($start)));
$end_month = strtotime(gmdate('Y-m-01', strtotime($end)));
while ($cursor <= $end_month) {
$month_key = gmdate('Y-m', $cursor);
$data[] = [
'label' => wp_date('M Y', $cursor),
'tooltip_label' => wp_date('F Y', $cursor),
'views' => (int) ($indexed[$month_key]['views'] ?? 0),
'unique_visitors' => (int) ($indexed[$month_key]['unique_visitors'] ?? 0),
];
$cursor = strtotime('+1 month', $cursor);
}
return $data;
}
protected function get_target_weekday_breakdown_between(array $target, string $start, string $end) {
$daily = $this->get_target_daily_combined_chart_data_between($target, $start, $end);
$map = [
__('Mon', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0],
__('Tue', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0],
__('Wed', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0],
__('Thu', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0],
__('Fri', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0],
__('Sat', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0],
__('Sun', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0],
];
$keys = array_keys($map);
foreach ($daily as $row) {
$timestamp = strtotime((string) ($row['date'] ?? ''));
if (!$timestamp) {
continue;
}
$index = (int) gmdate('N', $timestamp) - 1;
if (isset($keys[$index])) {
$map[$keys[$index]]['views'] += (int) ($row['views'] ?? 0);
$map[$keys[$index]]['unique_visitors'] += (int) ($row['unique_visitors'] ?? 0);
}
}
$rows = [];
foreach ($map as $label => $vals) {
$rows[] = ['label' => $label, 'views' => (int) $vals['views'], 'unique_visitors' => (int) $vals['unique_visitors']];
}
return $rows;
}
protected function get_target_best_day(array $target) {
global $wpdb;
if (($target['kind'] ?? '') === 'singular') {
$table = self::post_daily_table_name();
$row = $wpdb->get_row($wpdb->prepare("SELECT stat_date, views, unique_visitors FROM {$table} WHERE post_id = %d ORDER BY views DESC, stat_date ASC LIMIT 1", (int) $target['post_id']), ARRAY_A);
} else {
$table = self::page_daily_table_name();
$row = $wpdb->get_row($wpdb->prepare("SELECT stat_date, views, unique_visitors FROM {$table} WHERE page_key = %s ORDER BY views DESC, stat_date ASC LIMIT 1", (string) $target['page_key']), ARRAY_A);
}
return is_array($row) ? $row : [];
}
protected function get_target_first_tracked_date(array $target) {
global $wpdb;
if (($target['kind'] ?? '') === 'singular') {
$table = self::post_daily_table_name();
$date = $wpdb->get_var($wpdb->prepare("SELECT MIN(stat_date) FROM {$table} WHERE post_id = %d", (int) $target['post_id']));
} else {
$table = self::page_daily_table_name();
$date = $wpdb->get_var($wpdb->prepare("SELECT MIN(stat_date) FROM {$table} WHERE page_key = %s", (string) $target['page_key']));
}
return is_string($date) ? $date : '';
}
protected function get_target_total_tracked_days(array $target) {
global $wpdb;
if (($target['kind'] ?? '') === 'singular') {
$table = self::post_daily_table_name();
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE post_id = %d", (int) $target['post_id']));
}
$table = self::page_daily_table_name();
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE page_key = %s", (string) $target['page_key']));
}
protected function build_single_export_url(string $target_url, string $start_date = '', string $end_date = '') {
$args = ['action' => 'ansico_stat_export_single_csv', 'target_url' => $target_url];
if ($start_date !== '') $args['start_date'] = $start_date;
if ($end_date !== '') $args['end_date'] = $end_date;
$url = add_query_arg($args, admin_url('admin-post.php'));
return wp_nonce_url($url, 'ansico_stat_export_single_csv');
}
protected function render_single_stats_summary_cards(array $items) {
echo '<div class="ansico-stat-cards">';
foreach ($items as $item) {
echo '<div class="ansico-stat-card">';
echo '<h3>' . esc_html((string) ($item['label'] ?? '')) . '</h3>';
echo '<p class="ansico-stat-card-value">' . esc_html((string) ($item['value'] ?? '0')) . '</p>';
if (!empty($item['description'])) {
echo '<p class="description">' . esc_html((string) $item['description']) . '</p>';
}
echo '</div>';
}
echo '</div>';
}
public function render_single_page_stats_page() {
if (!current_user_can('manage_options')) {
return;
}
$requested_url = $this->get_requested_single_stats_url();
$range = $this->get_single_stats_date_range();
$result = $this->resolve_single_stats_target($requested_url);
$target = !empty($result['success']) ? (array) ($result['target'] ?? []) : [];
$range_start = $range['start'];
$range_end = $range['end'];
$daily_chart = !empty($target) ? $this->get_target_daily_combined_chart_data_between($target, $range_start, $range_end) : [];
$monthly_chart = !empty($target) ? $this->get_target_monthly_combined_chart_data_between($target, $range_start, $range_end) : [];
$weekday_rows = !empty($target) ? $this->get_target_weekday_breakdown_between($target, $range_start, $range_end) : [];
$lifetime_views = !empty($target) ? $this->get_target_lifetime_views($target) : 0;
$lifetime_unique = !empty($target) ? $this->get_target_lifetime_unique_visitors($target) : 0;
$period_views = !empty($target) ? $this->get_target_period_views($target, $range_start, $range_end) : 0;
$period_unique = !empty($target) ? $this->get_target_period_unique_visitors($target, $range_start, $range_end) : 0;
$best_day = !empty($target) ? $this->get_target_best_day($target) : [];
$tracked_days = !empty($target) ? $this->get_target_total_tracked_days($target) : 0;
$first_tracked = !empty($target) ? $this->get_target_first_tracked_date($target) : '';
$export_url = !empty($target) ? $this->build_single_export_url($requested_url, $range_start, $range_end) : '';
?>
<div class="wrap ansico-stat-admin-wrap ansico-single-page-shell">
<div class="ansico-single-page-hero">
<div>
<h1><?php esc_html_e('Single page statistics', 'ansico-stat-plugin'); ?></h1>
<p class="description"><?php esc_html_e('Review page-level views, unique visitors and trend data for any tracked URL on your site.', 'ansico-stat-plugin'); ?></p>
</div>
</div>
<div class="ansico-single-page-toolbar">
<form method="get" action="<?php echo esc_url(admin_url('admin.php')); ?>" class="ansico-stat-date-form ansico-single-filter-form">
<input type="hidden" name="page" value="<?php echo esc_attr(self::MENU_SLUG_SINGLE); ?>" />
<div class="ansico-single-filter-field ansico-single-filter-field-url">
<label for="ansico-single-target-url"><?php esc_html_e('URL', 'ansico-stat-plugin'); ?></label>
<input type="url" id="ansico-single-target-url" name="target_url" value="<?php echo esc_attr($requested_url); ?>" class="regular-text" />
</div>
<div class="ansico-single-filter-field">
<label for="ansico-single-start-date"><?php esc_html_e('From', 'ansico-stat-plugin'); ?></label>
<input type="date" id="ansico-single-start-date" name="start_date" value="<?php echo esc_attr($range_start); ?>" />
</div>
<div class="ansico-single-filter-field">
<label for="ansico-single-end-date"><?php esc_html_e('To', 'ansico-stat-plugin'); ?></label>
<input type="date" id="ansico-single-end-date" name="end_date" value="<?php echo esc_attr($range_end); ?>" />
</div>
<div class="ansico-single-filter-actions">
<button type="submit" class="button button-primary"><?php esc_html_e('Update', 'ansico-stat-plugin'); ?></button>
<?php if ($export_url !== '') : ?>
<a class="button" href="<?php echo esc_url($export_url); ?>"><?php esc_html_e('Export CSV', 'ansico-stat-plugin'); ?></a>
<?php endif; ?>
</div>
</form>
</div>
<?php if (empty($result['success'])) : ?>
<div class="notice notice-warning"><p><?php echo esc_html((string) ($result['message'] ?? __('No page selected.', 'ansico-stat-plugin'))); ?></p></div>
<?php else : ?>
<div class="ansico-single-page-target-card">
<div class="ansico-single-page-target-meta">
<span class="ansico-single-page-target-badge"><?php echo !empty($target['type']) ? esc_html(ucfirst((string) $target['type'])) : esc_html__('Tracked URL', 'ansico-stat-plugin'); ?></span>
<h2><?php echo esc_html((string) ($target['label'] ?? '')); ?></h2>
<a href="<?php echo esc_url((string) ($target['url'] ?? '')); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html((string) ($target['url'] ?? '')); ?></a>
</div>
</div>
<?php $this->render_single_stats_summary_cards([
['label' => __('Views in selected period', 'ansico-stat-plugin'), 'value' => number_format_i18n($period_views)],
['label' => __('Unique visitors in selected period', 'ansico-stat-plugin'), 'value' => number_format_i18n($period_unique)],
['label' => __('Lifetime views', 'ansico-stat-plugin'), 'value' => number_format_i18n($lifetime_views)],
['label' => __('Lifetime unique visitors', 'ansico-stat-plugin'), 'value' => number_format_i18n($lifetime_unique)],
['label' => __('Tracked days', 'ansico-stat-plugin'), 'value' => number_format_i18n($tracked_days), 'description' => $first_tracked !== '' ? sprintf(__('Since %s', 'ansico-stat-plugin'), wp_date(get_option('date_format'), strtotime($first_tracked))) : ''],
['label' => __('Best day', 'ansico-stat-plugin'), 'value' => !empty($best_day['views']) ? number_format_i18n((int) $best_day['views']) : '0', 'description' => !empty($best_day['stat_date']) ? wp_date(get_option('date_format'), strtotime((string) $best_day['stat_date'])) : ''],
]); ?>
<div class="ansico-single-page-grid">
<section class="ansico-stat-card ansico-single-chart-card ansico-single-chart-card-wide">
<div class="ansico-stat-card-header">
<h2><?php esc_html_e('Views and unique visitors', 'ansico-stat-plugin'); ?></h2>
<p><?php esc_html_e('Daily development for the selected date range.', 'ansico-stat-plugin'); ?></p>
</div>
<?php echo $this->render_multi_line_chart_markup($daily_chart, 'ansico-single-daily-chart', ['label_key' => 'label', 'x_fallback_key' => 'date', 'aria_label' => __('Views and unique visitors for selected page', 'ansico-stat-plugin'), 'max_x_labels' => 10]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</section>
<section class="ansico-stat-card ansico-single-chart-card ansico-single-chart-card-wide">
<div class="ansico-stat-card-header">
<h2><?php esc_html_e('Monthly totals', 'ansico-stat-plugin'); ?></h2>
<p><?php esc_html_e('Combined monthly totals for the selected page.', 'ansico-stat-plugin'); ?></p>
</div>
<?php echo $this->render_multi_line_chart_markup($monthly_chart, 'ansico-single-monthly-chart', ['label_key' => 'label', 'x_fallback_key' => 'label', 'aria_label' => __('Monthly totals for selected page', 'ansico-stat-plugin'), 'max_x_labels' => 12]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</section>
<section class="ansico-stat-card ansico-single-chart-card ansico-single-chart-card-wide">
<div class="ansico-stat-card-header">
<h2><?php esc_html_e('Weekday distribution', 'ansico-stat-plugin'); ?></h2>
<p><?php esc_html_e('See whether the page performs best on weekdays or weekends.', 'ansico-stat-plugin'); ?></p>
</div>
<?php echo $this->render_lifetime_weekday_grouped_bar_chart($weekday_rows, 'ansico-single-weekday-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</section>
</div>
<?php endif; ?>
</div>
<?php
}
public function handle_export_single_csv() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to export statistics.', 'ansico-stat-plugin'));
}
check_admin_referer('ansico_stat_export_single_csv');
$requested_url = $this->get_requested_single_stats_url();
$range = $this->get_single_stats_date_range();
$result = $this->resolve_single_stats_target($requested_url);
if (empty($result['success'])) {
wp_safe_redirect($this->build_single_stats_admin_url($requested_url, $range['start'], $range['end']));
exit;
}
$target = (array) ($result['target'] ?? []);
$rows = $this->get_target_daily_combined_chart_data_between($target, $range['start'], $range['end']);
$filename_slug = sanitize_title((string) ($target['label'] ?? 'single-page'));
if ($filename_slug === '') {
$filename_slug = 'single-page';
}
nocache_headers();
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="ansico-single-page-' . $filename_slug . '-' . wp_date('Y-m-d-H-i-s') . '.csv"');
2026-04-17 21:56:44 +00:00
$output = fopen('php://output', 'w');
2026-04-18 21:38:56 +00:00
if ($output === false) {
exit;
}
fputcsv($output, ['date', 'views', 'unique_visitors']);
foreach ($rows as $row) {
fputcsv($output, [(string) ($row['date'] ?? ''), (int) ($row['views'] ?? 0), (int) ($row['unique_visitors'] ?? 0)]);
2026-04-17 21:56:44 +00:00
}
fclose($output);
exit;
}
2026-04-15 19:53:03 +00:00
public function handle_export_csv() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to export statistics.', 'ansico-stat-plugin'));
}
check_admin_referer('ansico_stat_export_csv');
$filename = 'ansico-stats-' . wp_date('Y-m-d-H-i-s') . '.csv';
$install_month = $this->get_install_month_key();
$current_month = wp_date('Y-m', current_time('timestamp'));
nocache_headers();
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename=' . $filename);
$output = fopen('php://output', 'w');
fputcsv($output, ['Period type', 'Period', 'Rank', 'Title', 'Post ID', 'Post Type', 'Views', 'Lifetime Views', 'Permalink']);
$cursor = strtotime($install_month . '-01');
$end = strtotime($current_month . '-01');
while ($cursor <= $end) {
$month_key = wp_date('Y-m', $cursor);
$rows = $this->get_monthly_top_posts($month_key, 10);
foreach ($rows as $index => $row) {
$post_id = (int) $row['post_id'];
fputcsv($output, [
'month',
$month_key,
$index + 1,
get_the_title($post_id) ?: '(no title)',
$post_id,
get_post_type($post_id),
(int) $row['period_views'],
(int) get_post_meta($post_id, self::TOTAL_META_KEY, true),
get_permalink($post_id),
]);
}
$cursor = strtotime('+1 month', $cursor);
}
for ($year = $this->get_install_year(); $year <= (int) wp_date('Y', current_time('timestamp')); $year++) {
$rows = $this->get_yearly_top_posts($year, 10);
foreach ($rows as $index => $row) {
$post_id = (int) $row['post_id'];
fputcsv($output, [
'year',
$year,
$index + 1,
get_the_title($post_id) ?: '(no title)',
$post_id,
get_post_type($post_id),
(int) $row['period_views'],
(int) get_post_meta($post_id, self::TOTAL_META_KEY, true),
get_permalink($post_id),
]);
}
}
fclose($output);
exit;
}
public function handle_save_settings() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to save these settings.', 'ansico-stat-plugin'));
}
check_admin_referer('ansico_stat_save_settings');
$allowed_post_types = $this->get_default_track_post_types();
2026-04-18 21:38:56 +00:00
$existing_settings = $this->get_settings();
$settings_section = isset($_POST['settings_section']) ? sanitize_key(wp_unslash($_POST['settings_section'])) : 'tracking';
$submitted_post_types = ($settings_section === 'tracking' && isset($_POST['track_post_types']) && is_array($_POST['track_post_types'])) ? array_map('sanitize_key', wp_unslash($_POST['track_post_types'])) : $existing_settings['track_post_types'];
2026-04-15 19:53:03 +00:00
$submitted_post_types = array_values(array_intersect($allowed_post_types, $submitted_post_types));
2026-04-18 21:38:56 +00:00
$visitor_counting_mode = ($settings_section === 'tracking' && isset($_POST['visitor_counting_mode'])) ? sanitize_key(wp_unslash($_POST['visitor_counting_mode'])) : $existing_settings['visitor_counting_mode'];
2026-04-15 19:53:03 +00:00
if (!in_array($visitor_counting_mode, ['count_all', 'exclude_admins', 'exclude_all_logged_in'], true)) {
2026-04-18 21:38:56 +00:00
$visitor_counting_mode = $existing_settings['visitor_counting_mode'];
2026-04-15 19:53:03 +00:00
}
$settings = [
2026-04-18 21:38:56 +00:00
'visitor_counting_mode' => $visitor_counting_mode,
'exclude_known_bots' => $settings_section === 'tracking' ? (isset($_POST['exclude_known_bots']) ? 1 : 0) : $existing_settings['exclude_known_bots'],
'top_list_rows' => ($settings_section === 'tracking' && isset($_POST['top_list_rows'])) ? max(1, min(100, (int) $_POST['top_list_rows'])) : $existing_settings['top_list_rows'],
'referral_rows' => 0,
'revisit_minutes' => ($settings_section === 'tracking' && isset($_POST['revisit_minutes'])) ? max(1, min(10080, (int) $_POST['revisit_minutes'])) : $existing_settings['revisit_minutes'],
'frontend_label' => ($settings_section === 'tracking' && isset($_POST['frontend_label'])) ? sanitize_text_field(wp_unslash($_POST['frontend_label'])) : $existing_settings['frontend_label'],
'track_post_types' => $submitted_post_types,
'track_front_page' => $settings_section === 'tracking' ? (isset($_POST['track_front_page']) ? 1 : 0) : $existing_settings['track_front_page'],
'track_posts_page' => $settings_section === 'tracking' ? (isset($_POST['track_posts_page']) ? 1 : 0) : $existing_settings['track_posts_page'],
'track_archives' => isset($_POST['track_archives']) ? 1 : (array_key_exists('track_archives', $_POST) ? 0 : $existing_settings['track_archives']),
'track_search' => isset($_POST['track_search']) ? 1 : (array_key_exists('track_search', $_POST) ? 0 : $existing_settings['track_search']),
'track_404' => isset($_POST['track_404']) ? 1 : (array_key_exists('track_404', $_POST) ? 0 : $existing_settings['track_404']),
'ipinfo_token' => ($settings_section === 'tracking' && isset($_POST['ipinfo_token'])) ? sanitize_text_field(wp_unslash($_POST['ipinfo_token'])) : $existing_settings['ipinfo_token'],
'google_client_id' => ($settings_section === 'google' && isset($_POST['google_client_id'])) ? sanitize_text_field(wp_unslash($_POST['google_client_id'])) : $existing_settings['google_client_id'],
'google_client_secret' => ($settings_section === 'google' && isset($_POST['google_client_secret'])) ? sanitize_text_field(wp_unslash($_POST['google_client_secret'])) : $existing_settings['google_client_secret'],
'google_property' => ($settings_section === 'google' && isset($_POST['google_property'])) ? sanitize_text_field(wp_unslash($_POST['google_property'])) : $existing_settings['google_property'],
'google_access_token' => $existing_settings['google_access_token'],
'google_refresh_token' => $existing_settings['google_refresh_token'],
'google_token_expires_at' => $existing_settings['google_token_expires_at'],
'google_connected_email' => $existing_settings['google_connected_email'],
'google_site_verification_token' => $existing_settings['google_site_verification_token'],
'google_site_verification_method' => $existing_settings['google_site_verification_method'],
'google_last_sync_month' => $existing_settings['google_last_sync_month'],
2026-04-15 19:53:03 +00:00
];
update_option(self::OPTION_KEY, $settings, false);
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&settings-updated=true'));
exit;
}
public function handle_reset_all_views() {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to reset statistics.', 'ansico-stat-plugin'));
}
check_admin_referer('ansico_stat_reset_all');
global $wpdb;
$wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
self::TOTAL_META_KEY
));
$wpdb->query('TRUNCATE TABLE ' . self::daily_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::post_daily_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::page_daily_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::referral_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::dimension_table_name());
2026-04-18 21:38:56 +00:00
$wpdb->query('TRUNCATE TABLE ' . self::landing_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::unique_total_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::unique_page_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::unique_landing_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::unique_referral_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::unique_dimension_table_name());
$wpdb->query('TRUNCATE TABLE ' . self::search_query_table_name());
2026-04-15 19:53:03 +00:00
update_option(self::INSTALL_OPTION_KEY, current_time('Y-m-d'), false);
wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&reset=success'));
exit;
}
public function register_admin_columns() {
if (!is_admin()) {
return;
}
$post_types = get_post_types(['show_ui' => true], 'names');
foreach ($post_types as $post_type) {
add_filter("manage_{$post_type}_posts_columns", [$this, 'add_view_columns']);
add_action("manage_{$post_type}_posts_custom_column", [$this, 'render_view_columns'], 10, 2);
add_filter("manage_edit-{$post_type}_sortable_columns", [$this, 'register_sortable_view_columns']);
}
add_filter('manage_pages_columns', [$this, 'add_view_columns']);
add_action('manage_pages_custom_column', [$this, 'render_view_columns'], 10, 2);
add_filter('manage_edit-page_sortable_columns', [$this, 'register_sortable_view_columns']);
}
public function add_view_columns($columns) {
$new_columns = [];
foreach ($columns as $key => $label) {
$new_columns[$key] = $label;
if ($key === 'title') {
$new_columns['ansico_lifetime_views'] = __('Post Views (Lifetime)', 'ansico-stat-plugin');
$new_columns['ansico_latest_month_views'] = __('Post Views (Latest Month)', 'ansico-stat-plugin');
}
}
if (!isset($new_columns['ansico_lifetime_views'])) {
$new_columns['ansico_lifetime_views'] = __('Post Views (Lifetime)', 'ansico-stat-plugin');
$new_columns['ansico_latest_month_views'] = __('Post Views (Latest Month)', 'ansico-stat-plugin');
}
return $new_columns;
}
public function render_view_columns($column, $post_id) {
if ($column === 'ansico_lifetime_views') {
echo esc_html(number_format_i18n((int) get_post_meta($post_id, self::TOTAL_META_KEY, true)));
}
if ($column === 'ansico_latest_month_views') {
echo esc_html(number_format_i18n($this->get_post_views_for_latest_month((int) $post_id)));
}
}
public function register_sortable_view_columns($columns) {
$columns['ansico_lifetime_views'] = 'ansico_lifetime_views';
$columns['ansico_latest_month_views'] = 'ansico_latest_month_views';
return $columns;
}
public function render_admin_view_filter() {
global $typenow, $pagenow;
if (!is_admin() || $pagenow !== 'edit.php' || empty($typenow)) {
return;
}
$post_type_object = get_post_type_object($typenow);
if (!$post_type_object || empty($post_type_object->show_ui)) {
return;
}
$selected = isset($_GET['ansico_view_filter']) ? sanitize_text_field(wp_unslash($_GET['ansico_view_filter'])) : '';
?>
<select name="ansico_view_filter">
<option value=""><?php echo esc_html__('View stats filter', 'ansico-stat-plugin'); ?></option>
<option value="lifetime_desc" <?php selected($selected, 'lifetime_desc'); ?>><?php echo esc_html__('Most viewed (lifetime)', 'ansico-stat-plugin'); ?></option>
<option value="lifetime_asc" <?php selected($selected, 'lifetime_asc'); ?>><?php echo esc_html__('Least viewed (lifetime)', 'ansico-stat-plugin'); ?></option>
<option value="month_desc" <?php selected($selected, 'month_desc'); ?>><?php echo esc_html__('Most viewed (latest month)', 'ansico-stat-plugin'); ?></option>
<option value="month_asc" <?php selected($selected, 'month_asc'); ?>><?php echo esc_html__('Least viewed (latest month)', 'ansico-stat-plugin'); ?></option>
</select>
<?php
}
public function handle_admin_list_sorting($query) {
if (!is_admin() || !$query->is_main_query()) {
return;
}
$view_filter = isset($_GET['ansico_view_filter']) ? sanitize_text_field(wp_unslash($_GET['ansico_view_filter'])) : '';
if ($view_filter === 'lifetime_desc' || $view_filter === 'lifetime_asc') {
$query->set('meta_key', self::TOTAL_META_KEY);
$query->set('orderby', 'meta_value_num');
$query->set('order', $view_filter === 'lifetime_asc' ? 'ASC' : 'DESC');
}
if ($view_filter === 'month_desc' || $view_filter === 'month_asc') {
$query->set('ansico_sort_latest_month_views', 1);
$query->set('order', $view_filter === 'month_asc' ? 'ASC' : 'DESC');
}
$orderby = $query->get('orderby');
if ($orderby === 'ansico_lifetime_views') {
$query->set('meta_key', self::TOTAL_META_KEY);
$query->set('orderby', 'meta_value_num');
}
if ($orderby === 'ansico_latest_month_views') {
$query->set('ansico_sort_latest_month_views', 1);
}
}
public function add_monthly_views_sorting_clauses($clauses, $query) {
if (!is_admin() || !$query->is_main_query() || !$query->get('ansico_sort_latest_month_views')) {
return $clauses;
}
global $wpdb;
$table = self::post_daily_table_name();
$range = $this->get_latest_month_range();
$order = strtoupper((string) $query->get('order')) === 'ASC' ? 'ASC' : 'DESC';
$join_alias = 'ansico_monthly_views';
if (strpos($clauses['join'], "AS {$join_alias}") === false) {
$clauses['join'] .= $wpdb->prepare(
" LEFT JOIN (
SELECT post_id, SUM(views) AS month_views
FROM {$table}
WHERE stat_date BETWEEN %s AND %s
GROUP BY post_id
) AS {$join_alias} ON {$wpdb->posts}.ID = {$join_alias}.post_id",
$range['start'],
$range['end']
);
}
$clauses['orderby'] = "COALESCE({$join_alias}.month_views, 0) {$order}, {$wpdb->posts}.post_date DESC";
return $clauses;
}
}
new Ansico_Stat_Plugin();
}