diff --git a/ansico-stat-plugin-v1.0.0.9.zip b/ansico-stat-plugin-v1.0.0.9.zip deleted file mode 100644 index 0c61d37..0000000 Binary files a/ansico-stat-plugin-v1.0.0.9.zip and /dev/null differ diff --git a/ansico-stat-plugin-v1.1.0.1.zip b/ansico-stat-plugin-v1.1.0.1.zip new file mode 100644 index 0000000..34ac35a Binary files /dev/null and b/ansico-stat-plugin-v1.1.0.1.zip differ diff --git a/ansico-stat-plugin-v1.1.0.1.zip:Zone.Identifier b/ansico-stat-plugin-v1.1.0.1.zip:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/ansico-stat-plugin-v1.1.0.1.zip:Zone.Identifier differ diff --git a/ansico-stat-plugin/ansico-stat-plugin.php b/ansico-stat-plugin/ansico-stat-plugin.php index e3c5a25..97b1abe 100644 --- a/ansico-stat-plugin/ansico-stat-plugin.php +++ b/ansico-stat-plugin/ansico-stat-plugin.php @@ -3,7 +3,7 @@ * Plugin Name: Ansico Stat Plugin * Plugin URI: https://ansico.dk/Ansico/Ansico-Stat-plugin * Description: Simple WP plugin with basic website statistics. - * Version: 1.0.0.3 + * Version: 1.1.0.1 * Author: Andreas Andersen (Ansico) * Author URI: https://ansico.dk * Text Domain: ansico-stat-plugin @@ -19,7 +19,7 @@ if (!defined('ABSPATH')) { if (!class_exists('Ansico_Stat_Plugin')) { class Ansico_Stat_Plugin { - const VERSION = '1.0.0.3'; + const VERSION = '1.1.0.1'; const OPTION_KEY = 'ansico_stat_plugin_settings'; const INSTALL_OPTION_KEY = 'ansico_stat_plugin_install_date'; const TOTAL_META_KEY = 'post_views_count'; @@ -29,6 +29,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { const MENU_SLUG_SETTINGS = 'ansico-stat-plugin-settings'; const MENU_SLUG_STATS = 'ansico-stat-plugin'; const MENU_SLUG_YEARLY = 'ansico-stat-plugin-yearly'; + const MENU_SLUG_SINGLE = 'ansico-stat-plugin-single'; const COOKIE_TTL_MESSAGE = 1800; public function __construct() { @@ -42,9 +43,11 @@ if (!class_exists('Ansico_Stat_Plugin')) { add_action('wp_footer', [$this, 'render_admin_view_count_fallback'], 99); add_action('admin_menu', [$this, 'register_admin_page']); + add_action('admin_bar_menu', [$this, 'register_frontend_admin_bar_link'], 90); 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']); + add_action('admin_post_ansico_stat_export_single_csv', [$this, 'handle_export_single_csv']); 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']); @@ -75,6 +78,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { post_id bigint(20) unsigned NOT NULL, stat_date date NOT NULL, views bigint(20) unsigned NOT NULL DEFAULT 0, + unique_visitors bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (post_id, stat_date), KEY stat_date (stat_date), KEY views (views) @@ -88,6 +92,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { page_url text NULL, stat_date date NOT NULL, views bigint(20) unsigned NOT NULL DEFAULT 0, + unique_visitors bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (page_key, stat_date), KEY stat_date (stat_date), KEY page_type (page_type), @@ -528,21 +533,33 @@ if (!class_exists('Ansico_Stat_Plugin')) { return; } - $cookie_name = self::COOKIE_PREFIX . md5($page_context['cookie_key']); - if (isset($_COOKIE[$cookie_name])) { + $view_cookie_name = self::COOKIE_PREFIX . md5($page_context['cookie_key']); + $unique_cookie_name = self::COOKIE_PREFIX . 'unique_' . md5($page_context['cookie_key'] . '|' . current_time('Y-m-d')); + $is_unique_visitor = !isset($_COOKIE[$unique_cookie_name]); + + if (isset($_COOKIE[$view_cookie_name])) { return; } $referer_data = $this->get_referer_data(); if ($page_context['kind'] === 'singular') { - $this->increment_post_views((int) $page_context['post_id'], $referer_data); + $this->increment_post_views((int) $page_context['post_id'], $referer_data, $is_unique_visitor); } else { - $this->increment_non_singular_views($page_context, $referer_data); + $this->increment_non_singular_views($page_context, $referer_data, $is_unique_visitor); } if (!headers_sent()) { - setcookie($cookie_name, '1', time() + $this->get_cookie_ttl(), COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); - $_COOKIE[$cookie_name] = '1'; + setcookie($view_cookie_name, '1', time() + $this->get_cookie_ttl(), COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); + $_COOKIE[$view_cookie_name] = '1'; + + if ($is_unique_visitor) { + $expires = strtotime('tomorrow', current_time('timestamp')); + if ($expires <= time()) { + $expires = time() + DAY_IN_SECONDS; + } + setcookie($unique_cookie_name, '1', $expires, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); + $_COOKIE[$unique_cookie_name] = '1'; + } } } @@ -681,7 +698,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { return null; } - protected function increment_post_views(int $post_id, array $referer_data = []) { + protected function increment_post_views(int $post_id, array $referer_data = [], bool $is_unique_visitor = false) { global $wpdb; $today = current_time('Y-m-d'); @@ -701,11 +718,12 @@ if (!class_exists('Ansico_Stat_Plugin')) { )); $wpdb->query($wpdb->prepare( - "INSERT INTO {$post_daily_table} (post_id, stat_date, views) - VALUES (%d, %s, 1) - ON DUPLICATE KEY UPDATE views = views + 1", + "INSERT INTO {$post_daily_table} (post_id, stat_date, views, unique_visitors) + VALUES (%d, %s, 1, %d) + ON DUPLICATE KEY UPDATE views = views + 1, unique_visitors = unique_visitors + VALUES(unique_visitors)", $post_id, - $today + $today, + $is_unique_visitor ? 1 : 0 )); $category = isset($referer_data['category']) ? sanitize_key($referer_data['category']) : 'direct'; @@ -725,7 +743,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { $this->increment_dimension_views($today); } - protected function increment_non_singular_views(array $page_context, array $referer_data = []) { + protected function increment_non_singular_views(array $page_context, array $referer_data = [], bool $is_unique_visitor = false) { global $wpdb; $today = current_time('Y-m-d'); @@ -747,15 +765,16 @@ if (!class_exists('Ansico_Stat_Plugin')) { $url = isset($page_context['url']) ? esc_url_raw((string) $page_context['url']) : ''; $wpdb->query($wpdb->prepare( - "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)", + "INSERT INTO {$page_daily_table} (page_key, page_type, object_id, page_label, page_url, stat_date, views, unique_visitors) + VALUES (%s, %s, %s, %s, %s, %s, 1, %d) + ON DUPLICATE KEY UPDATE views = views + 1, unique_visitors = unique_visitors + VALUES(unique_visitors), page_label = VALUES(page_label), page_url = VALUES(page_url)", $page_key, $page_type, $object_id, $label, $url, - $today + $today, + $is_unique_visitor ? 1 : 0 )); $category = isset($referer_data['category']) ? sanitize_key($referer_data['category']) : 'direct'; @@ -921,6 +940,15 @@ if (!class_exists('Ansico_Stat_Plugin')) { [$this, 'render_yearly_stats_page'] ); + 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'] + ); + add_submenu_page( self::MENU_SLUG_STATS, __('Settings', 'ansico-stat-plugin'), @@ -936,6 +964,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { 'index.php', 'toplevel_page_' . self::MENU_SLUG_STATS, 'ansico-stat-plugin_page_' . self::MENU_SLUG_YEARLY, + 'ansico-stat-plugin_page_' . self::MENU_SLUG_SINGLE, 'ansico-stat-plugin_page_' . self::MENU_SLUG_SETTINGS, ], true)) { return; @@ -1962,6 +1991,1146 @@ if (!class_exists('Ansico_Stat_Plugin')) { 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), + 'is_custom' => isset($_GET['start_date']) || isset($_GET['end_date']), + ]; + } + + 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) || 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' => '' . esc_html__('Page stats', 'ansico-stat-plugin') . '', + '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'), + 'requested_url' => $url, + 'normalized_url' => '', + ]; + } + + $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'), + 'requested_url' => $url, + 'normalized_url' => $normalized_url, + ]; + } + + $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, + 'requested_url' => $url, + 'normalized_url' => $normalized_url, + 'target' => [ + 'kind' => 'singular', + 'post_id' => (int) $post_id, + 'post_type' => sanitize_key((string) $post->post_type), + '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) ?: $normalized_url, + 'resolved_via' => 'post', + ], + ]; + } + } + + $front_url = $this->normalize_target_url(home_url('/')); + if ($normalized_url === $front_url) { + return [ + 'success' => true, + 'requested_url' => $url, + 'normalized_url' => $normalized_url, + 'target' => [ + 'kind' => 'archive', + 'page_key' => 'front_page', + 'page_type' => 'front_page', + 'object_id' => 0, + 'label' => __('Front page', 'ansico-stat-plugin'), + 'url' => home_url('/'), + 'resolved_via' => 'front_page', + ], + ]; + } + + $posts_page_id = (int) get_option('page_for_posts'); + if ($posts_page_id > 0) { + $posts_page_url = $this->normalize_target_url((string) get_permalink($posts_page_id)); + if ($posts_page_url !== '' && $normalized_url === $posts_page_url) { + return [ + 'success' => true, + 'requested_url' => $url, + 'normalized_url' => $normalized_url, + 'target' => [ + 'kind' => 'archive', + 'page_key' => 'home', + 'page_type' => 'home', + 'object_id' => $posts_page_id, + 'label' => __('Posts page', 'ansico-stat-plugin'), + 'url' => get_permalink($posts_page_id) ?: $normalized_url, + 'resolved_via' => 'posts_page', + ], + ]; + } + } + + $page_daily_table = self::page_daily_table_name(); + $url_variants = array_values(array_unique(array_filter([ + $normalized_url, + untrailingslashit($normalized_url), + trailingslashit($normalized_url), + ]))); + + if (!empty($url_variants)) { + $placeholders = implode(',', array_fill(0, count($url_variants), '%s')); + $sql = "SELECT page_key, page_type, object_id, page_label, page_url\n FROM {$page_daily_table}\n WHERE page_url IN ({$placeholders})\n ORDER BY stat_date DESC\n LIMIT 1"; + $row = $wpdb->get_row($wpdb->prepare($sql, $url_variants), ARRAY_A); + if (is_array($row) && !empty($row['page_key'])) { + return [ + 'success' => true, + 'requested_url' => $url, + 'normalized_url' => $normalized_url, + 'target' => [ + 'kind' => 'archive', + 'page_key' => (string) $row['page_key'], + 'page_type' => sanitize_key((string) ($row['page_type'] ?? 'archive')), + 'object_id' => !empty($row['object_id']) ? (int) $row['object_id'] : 0, + 'label' => sanitize_text_field((string) ($row['page_label'] ?? $normalized_url)), + 'url' => !empty($row['page_url']) ? esc_url_raw((string) $row['page_url']) : $normalized_url, + 'resolved_via' => 'page_daily', + ], + ]; + } + } + + return [ + 'success' => false, + 'message' => __('No tracked page or post was found for that URL yet. Try opening the page on the site first so the plugin can register it, and then update this report again.', 'ansico-stat-plugin'), + 'requested_url' => $url, + 'normalized_url' => $normalized_url, + ]; + } + + 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(); + $views = $wpdb->get_var($wpdb->prepare( + "SELECT SUM(views) FROM {$table} WHERE page_key = %s", + (string) $target['page_key'] + )); + + return (int) $views; + } + + + protected function get_target_lifetime_unique_visitors(array $target) { + global $wpdb; + + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + $uniques = $wpdb->get_var($wpdb->prepare( + "SELECT SUM(unique_visitors) FROM {$table} WHERE post_id = %d", + (int) $target['post_id'] + )); + return (int) $uniques; + } + + $table = self::page_daily_table_name(); + $uniques = $wpdb->get_var($wpdb->prepare( + "SELECT SUM(unique_visitors) FROM {$table} WHERE page_key = %s", + (string) $target['page_key'] + )); + return (int) $uniques; + } + + 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(); + $uniques = $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 + )); + return (int) $uniques; + } + + $table = self::page_daily_table_name(); + $uniques = $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 + )); + return (int) $uniques; + } + + protected function get_target_daily_chart_data_between(array $target, string $start, string $end, string $metric = 'views') { + global $wpdb; + + $metric = $metric === 'unique_visitors' ? 'unique_visitors' : 'views'; + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT stat_date, {$metric} AS metric_value 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, {$metric} AS metric_value 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']] = (int) $row['metric_value']; + } + + $data = []; + $cursor = strtotime($start); + $end_ts = strtotime($end); + while ($cursor <= $end_ts) { + $date = gmdate('Y-m-d', $cursor); + $data[] = [ + 'date' => $date, + 'views' => $indexed[$date] ?? 0, + ]; + $cursor = strtotime('+1 day', $cursor); + } + + return $data; + } + + protected function get_target_monthly_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 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 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']] = (int) $row['total_views']; + } + + $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), + 'views' => $indexed[$month_key] ?? 0, + ]; + $cursor = strtotime('+1 month', $cursor); + } + + return $data; + } + + protected function get_target_weekday_breakdown_between(array $target, string $start, string $end) { + $daily_data = $this->get_target_daily_chart_data_between($target, $start, $end, 'views'); + $weekday_rows = [ + __('Mon', 'ansico-stat-plugin') => 0, + __('Tue', 'ansico-stat-plugin') => 0, + __('Wed', 'ansico-stat-plugin') => 0, + __('Thu', 'ansico-stat-plugin') => 0, + __('Fri', 'ansico-stat-plugin') => 0, + __('Sat', 'ansico-stat-plugin') => 0, + __('Sun', 'ansico-stat-plugin') => 0, + ]; + $weekday_keys = array_keys($weekday_rows); + + foreach ($daily_data as $row) { + $timestamp = strtotime((string) ($row['date'] ?? '')); + if (!$timestamp) { + continue; + } + $weekday_index = (int) gmdate('N', $timestamp) - 1; + if (isset($weekday_keys[$weekday_index])) { + $weekday_rows[$weekday_keys[$weekday_index]] += (int) ($row['views'] ?? 0); + } + } + + $data = []; + foreach ($weekday_rows as $label => $views) { + $data[] = [ + 'label' => $label, + 'views' => (int) $views, + ]; + } + + return $data; + } + + protected function render_dual_chart_markup(array $views_data, array $unique_data, string $chart_id, string $aria_label = '') { + if ($aria_label === '') { + $aria_label = __('Views and unique visitors chart', 'ansico-stat-plugin'); + } + + $count = max(count($views_data), count($unique_data)); + if ($count < 1) { + return '

' . esc_html__('No chart data available.', 'ansico-stat-plugin') . '

'; + } + + $values = []; + foreach ($views_data as $row) { + $values[] = (int) ($row['views'] ?? 0); + } + foreach ($unique_data as $row) { + $values[] = (int) ($row['views'] ?? 0); + } + $max = max(1, !empty($values) ? max($values) : 1); + $width = 920; + $height = 240; + $chart_top = 20; + $chart_bottom = 185; + $chart_left = 26; + $chart_right = 830; + $stepX = $count > 1 ? ($chart_right - $chart_left) / ($count - 1) : ($chart_right - $chart_left); + + $build_points = static function(array $series, string $series_label) use ($chart_top, $chart_bottom, $chart_left, $stepX, $max) { + $points = []; + foreach (array_values($series) as $index => $row) { + $value = (int) ($row['views'] ?? 0); + $x = $chart_left + ($stepX * $index); + $y = $value > 0 ? $chart_top + (($chart_bottom - $chart_top) * (1 - ($value / $max))) : $chart_bottom; + $label = (string) ($row['label'] ?? $row['date'] ?? ''); + $points[] = [ + 'x' => $x, + 'y' => $y, + 'label' => $label, + 'value' => $value, + 'series_label' => $series_label, + ]; + } + return $points; + }; + + $view_points = $build_points($views_data, __('Views', 'ansico-stat-plugin')); + $unique_points = $build_points($unique_data, __('Unique visitors', 'ansico-stat-plugin')); + $view_path = implode(' ', array_map(static function($point) { + return $point['x'] . ',' . $point['y']; + }, $view_points)); + $unique_path = implode(' ', array_map(static function($point) { + return $point['x'] . ',' . $point['y']; + }, $unique_points)); + + ob_start(); + ?> +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + date_days_ago($days - 1); + $end = current_time('Y-m-d'); + + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT stat_date, 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, 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']] = (int) $row['unique_visitors']; + } + + $data = []; + $cursor = strtotime($start); + $end_ts = strtotime($end); + while ($cursor <= $end_ts) { + $date = gmdate('Y-m-d', $cursor); + $data[] = [ + 'date' => $date, + 'views' => $indexed[$date] ?? 0, + ]; + $cursor = strtotime('+1 day', $cursor); + } + + return $data; + } + + protected function get_target_period_views(array $target, string $start, string $end) { + global $wpdb; + + if (($target['kind'] ?? '') === 'singular') { + $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", + (int) $target['post_id'], + $start, + $end + )); + return (int) $views; + } + + $table = self::page_daily_table_name(); + $views = $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 + )); + return (int) $views; + } + + protected function get_target_daily_chart_data(array $target, int $days = 60) { + global $wpdb; + + $days = max(1, $days); + $start = $this->date_days_ago($days - 1); + $end = current_time('Y-m-d'); + + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT stat_date, views 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 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']] = (int) $row['views']; + } + + $data = []; + $cursor = strtotime($start); + $end_ts = strtotime($end); + while ($cursor <= $end_ts) { + $date = gmdate('Y-m-d', $cursor); + $data[] = [ + 'date' => $date, + 'views' => $indexed[$date] ?? 0, + ]; + $cursor = strtotime('+1 day', $cursor); + } + + return $data; + } + + protected function get_target_monthly_chart_data(array $target, int $months = 12) { + global $wpdb; + + $months = max(1, $months); + $current_month = strtotime(wp_date('Y-m-01', current_time('timestamp'))); + $start_month = strtotime('-' . ($months - 1) . ' months', $current_month); + $start = wp_date('Y-m-01', $start_month); + $end = wp_date('Y-m-t', current_time('timestamp')); + + 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\n FROM {$table}\n WHERE post_id = %d AND stat_date BETWEEN %s AND %s\n GROUP BY month_key\n 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\n FROM {$table}\n WHERE page_key = %s AND stat_date BETWEEN %s AND %s\n GROUP BY month_key\n ORDER BY month_key ASC", + (string) $target['page_key'], + $start, + $end + ), ARRAY_A); + } + + $indexed = []; + foreach ((array) $rows as $row) { + $indexed[(string) $row['month_key']] = (int) $row['total_views']; + } + + $data = []; + $cursor = $start_month; + while ($cursor <= $current_month) { + $month_key = wp_date('Y-m', $cursor); + $data[] = [ + 'label' => wp_date('M Y', $cursor), + 'views' => $indexed[$month_key] ?? 0, + ]; + $cursor = strtotime('+1 month', $cursor); + } + + return $data; + } + + 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 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 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(); + $count = $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE post_id = %d", + (int) $target['post_id'] + )); + } else { + $table = self::page_daily_table_name(); + $count = $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE page_key = %s", + (string) $target['page_key'] + )); + } + + return (int) $count; + } + + + protected function get_target_average_unique_visitors_per_day(array $target, int $days) { + $days = max(1, $days); + $uniques = $this->get_target_period_unique_visitors($target, $this->date_days_ago($days - 1), current_time('Y-m-d')); + return $uniques / $days; + } + + protected function get_target_unique_period_comparison(array $target, int $days) { + $days = max(1, $days); + $current_end = current_time('Y-m-d'); + $current_start = $this->date_days_ago($days - 1); + $previous_end = $this->date_days_ago($days); + $previous_start = $this->date_days_ago(($days * 2) - 1); + + $current_uniques = $this->get_target_period_unique_visitors($target, $current_start, $current_end); + $previous_uniques = $this->get_target_period_unique_visitors($target, $previous_start, $previous_end); + $change = $current_uniques - $previous_uniques; + $percent_change = null; + if ($previous_uniques > 0) { + $percent_change = (($current_uniques - $previous_uniques) / $previous_uniques) * 100; + } elseif ($current_uniques > 0) { + $percent_change = 100.0; + } + + return [ + 'days' => $days, + 'current_views' => (int) $current_uniques, + 'previous_views' => (int) $previous_uniques, + 'change' => (int) $change, + 'percent_change' => $percent_change, + 'current_start' => $current_start, + 'current_end' => $current_end, + 'previous_start' => $previous_start, + 'previous_end' => $previous_end, + ]; + } + + protected function get_target_average_views_per_day(array $target, int $days) { + $days = max(1, $days); + $views = $this->get_target_period_views($target, $this->date_days_ago($days - 1), current_time('Y-m-d')); + return $views / $days; + } + + protected function get_target_period_comparison(array $target, int $days) { + $days = max(1, $days); + $current_end = current_time('Y-m-d'); + $current_start = $this->date_days_ago($days - 1); + $previous_end = $this->date_days_ago($days); + $previous_start = $this->date_days_ago(($days * 2) - 1); + + $current_views = $this->get_target_period_views($target, $current_start, $current_end); + $previous_views = $this->get_target_period_views($target, $previous_start, $previous_end); + $change = $current_views - $previous_views; + $percent_change = null; + if ($previous_views > 0) { + $percent_change = (($current_views - $previous_views) / $previous_views) * 100; + } elseif ($current_views > 0) { + $percent_change = 100.0; + } + + return [ + 'days' => $days, + 'current_views' => (int) $current_views, + 'previous_views' => (int) $previous_views, + 'change' => (int) $change, + 'percent_change' => $percent_change, + 'current_start' => $current_start, + 'current_end' => $current_end, + 'previous_start' => $previous_start, + 'previous_end' => $previous_end, + ]; + } + + protected function format_change_percent($percent_change) { + if ($percent_change === null) { + return __('New data', 'ansico-stat-plugin'); + } + + $prefix = $percent_change > 0 ? '+' : ''; + return $prefix . number_format_i18n((float) $percent_change, 1) . '%'; + } + + protected function get_target_weekday_breakdown(array $target, int $days = 90) { + $days = max(1, $days); + $daily_data = $this->get_target_daily_chart_data($target, $days); + $weekday_rows = [ + __('Mon', 'ansico-stat-plugin') => 0, + __('Tue', 'ansico-stat-plugin') => 0, + __('Wed', 'ansico-stat-plugin') => 0, + __('Thu', 'ansico-stat-plugin') => 0, + __('Fri', 'ansico-stat-plugin') => 0, + __('Sat', 'ansico-stat-plugin') => 0, + __('Sun', 'ansico-stat-plugin') => 0, + ]; + $weekday_keys = array_keys($weekday_rows); + + foreach ($daily_data as $row) { + $timestamp = strtotime((string) ($row['date'] ?? '')); + if (!$timestamp) { + continue; + } + $weekday_index = (int) gmdate('N', $timestamp) - 1; + if (!isset($weekday_keys[$weekday_index])) { + continue; + } + $weekday_rows[$weekday_keys[$weekday_index]] += (int) ($row['views'] ?? 0); + } + + $data = []; + foreach ($weekday_rows as $label => $views) { + $data[] = [ + 'label' => $label, + 'views' => (int) $views, + ]; + } + + return $data; + } + + 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; + } + + return wp_nonce_url( + add_query_arg($args, admin_url('admin-post.php')), + 'ansico_stat_export_single_csv' + ); + } + + protected function render_single_stats_summary_cards(array $items) { + echo '
'; + foreach ($items as $item) { + echo '
'; + echo '
' . esc_html((string) ($item['label'] ?? '')) . '
'; + echo '
' . esc_html((string) ($item['value'] ?? '')) . '
'; + if (!empty($item['description'])) { + echo '
' . esc_html((string) $item['description']) . '
'; + } + echo '
'; + } + echo '
'; + } + + public function render_single_page_stats_page() { + if (!current_user_can('manage_options')) { + wp_die(esc_html__('You do not have permission to access this page.', 'ansico-stat-plugin')); + } + + $requested_url = $this->get_requested_single_stats_url(); + $date_range = $this->get_single_stats_date_range(); + $range_start = (string) $date_range['start']; + $range_end = (string) $date_range['end']; + $range_days = (int) $date_range['days']; + $result = $this->resolve_single_stats_target($requested_url); + $target = !empty($result['success']) ? (array) $result['target'] : []; + $daily_chart = !empty($target) ? $this->get_target_daily_chart_data_between($target, $range_start, $range_end, 'views') : []; + $unique_daily_chart = !empty($target) ? $this->get_target_daily_chart_data_between($target, $range_start, $range_end, 'unique_visitors') : []; + $monthly_chart = !empty($target) ? $this->get_target_monthly_chart_data_between($target, $range_start, $range_end) : []; + $weekday_chart = !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_visitors = !empty($target) ? $this->get_target_lifetime_unique_visitors($target) : 0; + $views_today = !empty($target) ? $this->get_target_period_views($target, current_time('Y-m-d'), current_time('Y-m-d')) : 0; + $unique_today = !empty($target) ? $this->get_target_period_unique_visitors($target, current_time('Y-m-d'), current_time('Y-m-d')) : 0; + $range_views = !empty($target) ? $this->get_target_period_views($target, $range_start, $range_end) : 0; + $range_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) : []; + $first_tracked = !empty($target) ? $this->get_target_first_tracked_date($target) : ''; + $tracked_days = !empty($target) ? $this->get_target_total_tracked_days($target) : 0; + $avg_range = $range_days > 0 ? ($range_views / $range_days) : 0; + $avg_unique_range = $range_days > 0 ? ($range_unique / $range_days) : 0; + $compare_range = !empty($target) ? $this->get_target_period_comparison($target, $range_days) : []; + $unique_compare_range = !empty($target) ? $this->get_target_unique_period_comparison($target, $range_days) : []; + $views_7 = !empty($target) ? $this->get_target_period_views($target, $this->date_days_ago(6), current_time('Y-m-d')) : 0; + $views_30 = !empty($target) ? $this->get_target_period_views($target, $this->date_days_ago(29), current_time('Y-m-d')) : 0; + $unique_7 = !empty($target) ? $this->get_target_period_unique_visitors($target, $this->date_days_ago(6), current_time('Y-m-d')) : 0; + $unique_30 = !empty($target) ? $this->get_target_period_unique_visitors($target, $this->date_days_ago(29), current_time('Y-m-d')) : 0; + $export_url = !empty($target) ? $this->build_single_export_url($requested_url, $range_start, $range_end) : ''; + $reset_range_url = $this->build_single_stats_admin_url($requested_url); + $post_type_label = ''; + if (!empty($target) && ($target['kind'] ?? '') === 'singular') { + $post_type_obj = get_post_type_object((string) ($target['post_type'] ?? '')); + $post_type_label = $post_type_obj && !empty($post_type_obj->labels->singular_name) ? (string) $post_type_obj->labels->singular_name : (string) ($target['post_type'] ?? ''); + } + ?> +
+

+

+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

+
+
+ + +
+

+
+ +
+
+
+

+

+

+
+
+ + +
+
+ render_single_stats_summary_cards([ + [ + 'label' => __('Views in selected period', 'ansico-stat-plugin'), + 'value' => number_format_i18n($range_views), + 'description' => sprintf(__('Avg. %s per day', 'ansico-stat-plugin'), number_format_i18n($avg_range, 1)), + ], + [ + 'label' => __('Unique visitors in selected period', 'ansico-stat-plugin'), + 'value' => number_format_i18n($range_unique), + 'description' => sprintf(__('Avg. %s per day', 'ansico-stat-plugin'), number_format_i18n($avg_unique_range, 1)), + ], + [ + 'label' => __('Views today', 'ansico-stat-plugin'), + 'value' => number_format_i18n($views_today), + 'description' => __('Today only', 'ansico-stat-plugin'), + ], + [ + 'label' => __('Unique visitors today', 'ansico-stat-plugin'), + 'value' => number_format_i18n($unique_today), + 'description' => __('Today only', 'ansico-stat-plugin'), + ], + [ + 'label' => __('Views last 7 days', 'ansico-stat-plugin'), + 'value' => number_format_i18n($views_7), + 'description' => __('Rolling 7-day total', 'ansico-stat-plugin'), + ], + [ + 'label' => __('Unique visitors last 7 days', 'ansico-stat-plugin'), + 'value' => number_format_i18n($unique_7), + 'description' => __('Rolling 7-day total', 'ansico-stat-plugin'), + ], + [ + 'label' => __('Views last 30 days', 'ansico-stat-plugin'), + 'value' => number_format_i18n($views_30), + 'description' => __('Rolling 30-day total', 'ansico-stat-plugin'), + ], + [ + 'label' => __('Unique visitors last 30 days', 'ansico-stat-plugin'), + 'value' => number_format_i18n($unique_30), + 'description' => __('Rolling 30-day total', 'ansico-stat-plugin'), + ], + [ + 'label' => __('Lifetime views', 'ansico-stat-plugin'), + 'value' => number_format_i18n($lifetime_views), + 'description' => $first_tracked !== '' ? sprintf(__('Tracked since %s', 'ansico-stat-plugin'), wp_date(get_option('date_format'), strtotime($first_tracked))) : '', + ], + [ + 'label' => __('Lifetime unique visitors', 'ansico-stat-plugin'), + 'value' => number_format_i18n($lifetime_unique_visitors), + 'description' => __('Stored from version 1.0.0.6 and forward', 'ansico-stat-plugin'), + ], + [ + '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'])) : __('No tracked data yet', 'ansico-stat-plugin'), + ], + [ + 'label' => __('Tracked days with views', 'ansico-stat-plugin'), + 'value' => number_format_i18n($tracked_days), + 'description' => __('Days stored in the daily statistics table', 'ansico-stat-plugin'), + ], + ]); ?> +

+
+ +
+

+ render_dual_chart_markup($daily_chart, $unique_daily_chart, 'ansico-single-target-dual-chart', __('Views and unique visitors for selected page', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+

+ render_chart_markup($daily_chart, 'ansico-single-target-daily-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+

+ render_chart_markup($unique_daily_chart, 'ansico-single-target-unique-daily-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+

+ render_bar_chart_markup($monthly_chart, 'ansico-single-target-monthly-chart', __('Monthly totals for selected page', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+

+ render_bar_chart_markup($weekday_chart, 'ansico-single-target-weekday-chart', __('Views by weekday for selected page', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
format_change_percent($compare_range['percent_change'] ?? null)); ?>
format_change_percent($unique_compare_range['percent_change'] ?? null)); ?>
+
+ +
+ resolve_single_stats_target($requested_url); + if (empty($result['success']) || empty($result['target'])) { + wp_die(esc_html__('No tracked page could be resolved for export.', 'ansico-stat-plugin')); + } + + $target = (array) $result['target']; + $start_date = isset($_GET['start_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['start_date'])) : ''; + $end_date = isset($_GET['end_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['end_date'])) : ''; + if ($start_date === '') { + $start_date = $this->date_days_ago(29); + } + if ($end_date === '') { + $end_date = current_time('Y-m-d'); + } + if (strtotime($start_date) > strtotime($end_date)) { + [$start_date, $end_date] = [$end_date, $start_date]; + } + $daily_rows = $this->get_target_daily_chart_data_between($target, $start_date, $end_date, 'views'); + $range_days = max(1, (int) floor((strtotime($end_date) - strtotime($start_date)) / DAY_IN_SECONDS) + 1); + $compare_7 = $this->get_target_period_comparison($target, 7); + $compare_30 = $this->get_target_period_comparison($target, 30); + $compare_range = $this->get_target_period_comparison($target, $range_days); + $unique_compare_7 = $this->get_target_unique_period_comparison($target, 7); + $unique_compare_30 = $this->get_target_unique_period_comparison($target, 30); + $unique_compare_range = $this->get_target_unique_period_comparison($target, $range_days); + $filename_slug = sanitize_title((string) ($target['label'] ?? 'page-stats')); + if ($filename_slug === '') { + $filename_slug = 'page-stats'; + } + $filename = 'ansico-single-page-' . $filename_slug . '-' . wp_date('Y-m-d-H-i-s') . '.csv'; + + nocache_headers(); + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename=' . $filename); + + $output = fopen('php://output', 'w'); + fputcsv($output, ['Selected URL', (string) ($target['url'] ?? '')]); + fputcsv($output, ['Label', (string) ($target['label'] ?? '')]); + fputcsv($output, ['Type', (string) (($target['kind'] ?? '') === 'singular' ? ($target['post_type'] ?? 'post') : ($target['page_type'] ?? 'page'))]); + fputcsv($output, ['Selected period start', $start_date]); + fputcsv($output, ['Selected period end', $end_date]); + fputcsv($output, ['Selected period views', (int) $this->get_target_period_views($target, $start_date, $end_date)]); + fputcsv($output, ['Selected period unique visitors', (int) $this->get_target_period_unique_visitors($target, $start_date, $end_date)]); + fputcsv($output, ['Previous comparable period views', (int) ($compare_range['previous_views'] ?? 0)]); + fputcsv($output, ['Previous comparable period unique visitors', (int) ($unique_compare_range['previous_views'] ?? 0)]); + fputcsv($output, ['Lifetime views', (int) $this->get_target_lifetime_views($target)]); + fputcsv($output, ['Views today', (int) $this->get_target_period_views($target, current_time('Y-m-d'), current_time('Y-m-d'))]); + fputcsv($output, ['Unique visitors today', (int) $this->get_target_period_unique_visitors($target, current_time('Y-m-d'), current_time('Y-m-d'))]); + fputcsv($output, ['Views last 7 days', (int) ($compare_7['current_views'] ?? 0)]); + fputcsv($output, ['Unique visitors last 7 days', (int) ($unique_compare_7['current_views'] ?? 0)]); + fputcsv($output, ['Previous 7 days', (int) ($compare_7['previous_views'] ?? 0)]); + fputcsv($output, ['Previous unique visitors 7 days', (int) ($unique_compare_7['previous_views'] ?? 0)]); + fputcsv($output, ['Views last 30 days', (int) ($compare_30['current_views'] ?? 0)]); + fputcsv($output, ['Unique visitors last 30 days', (int) ($unique_compare_30['current_views'] ?? 0)]); + fputcsv($output, ['Previous 30 days', (int) ($compare_30['previous_views'] ?? 0)]); + fputcsv($output, ['Previous unique visitors 30 days', (int) ($unique_compare_30['previous_views'] ?? 0)]); + fputcsv($output, []); + fputcsv($output, ['Date', 'Views', 'Unique visitors']); + foreach ($daily_rows as $row) { + fputcsv($output, [ + (string) ($row['date'] ?? ''), + (int) ($row['views'] ?? 0), + (int) $this->get_target_period_unique_visitors($target, (string) ($row['date'] ?? ''), (string) ($row['date'] ?? '')), + ]); + } + fclose($output); + exit; + } + 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')); diff --git a/ansico-stat-plugin/assets/css/ansico-stat-admin.css b/ansico-stat-plugin/assets/css/ansico-stat-admin.css index 9c3ef4f..d3f56ca 100644 --- a/ansico-stat-plugin/assets/css/ansico-stat-admin.css +++ b/ansico-stat-plugin/assets/css/ansico-stat-admin.css @@ -351,3 +351,114 @@ box-shadow: 0 4px 16px rgba(0,0,0,.15); max-width: min(260px, calc(100% - 16px)); } + + +.ansico-stat-url-form-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: end; +} + +.ansico-stat-url-field { + min-width: 0; +} + +.ansico-stat-url-action .button { + min-width: 110px; +} + +.ansico-stat-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; + margin-top: 16px; +} + +.ansico-stat-summary-card { + border: 1px solid #dcdcde; + border-radius: 8px; + padding: 14px; + background: #f9f9f9; +} + +.ansico-stat-summary-label { + font-size: 12px; + line-height: 1.4; + color: #50575e; + margin-bottom: 6px; +} + +.ansico-stat-summary-value { + font-size: 28px; + line-height: 1.2; + font-weight: 600; +} + +.ansico-stat-summary-description { + font-size: 12px; + line-height: 1.4; + color: #50575e; + margin-top: 6px; +} + +.ansico-stat-target-badge { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: #eef4fb; + color: #1d4f82; + font-size: 12px; + font-weight: 600; +} + +@media (max-width: 782px) { + .ansico-stat-url-form-row { + grid-template-columns: 1fr; + } +} + + +.ansico-stat-url-form-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 160px 160px auto; + gap: 12px; + align-items: end; +} + +.ansico-stat-date-field input { + width: 100%; +} + +.ansico-stat-dual-chart-legend { + display: flex; + gap: 18px; + flex-wrap: wrap; + margin-top: 12px; + font-size: 13px; +} + +.ansico-stat-dual-chart-legend span { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ansico-stat-dual-chart-swatch { + display: inline-block; + width: 22px; + height: 0; + border-top: 3px solid #2271b1; +} + +.ansico-stat-dual-chart-swatch-unique { + border-top-color: #8c8f94; + border-top-style: dashed; +} + +@media (max-width: 960px) { + .ansico-stat-url-form-row { + grid-template-columns: 1fr; + } +} diff --git a/ansico-stat-plugin/readme.txt b/ansico-stat-plugin/readme.txt index 18e6a2e..6c6531e 100644 --- a/ansico-stat-plugin/readme.txt +++ b/ansico-stat-plugin/readme.txt @@ -5,7 +5,7 @@ Tags: analytics, statistics, views, post views, dashboard Requires at least: 6.0 Tested up to: 6.9.4 Requires PHP: 7.4 -Stable tag: 1.0.0 +Stable tag: 1.1.0.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -27,6 +27,7 @@ Features include: * Device, browser, and operating system breakdowns * Bot exclusion and logged-in user exclusion rules * Reset statistics from the settings page +* Single page statistics page with URL-based lookup, date range filters, per-page charts, unique visitor tracking from version 1.0.0.6 forward, and a frontend admin bar shortcut Support and documentation: https://ansico.dk/Ansico/Ansico-Stat-plugin @@ -58,6 +59,28 @@ https://ansico.dk/Ansico/Ansico-Stat-plugin == Changelog == += 1.1.0.1 = +- Added visible data points to the combined views and unique visitors chart. +- Added hover tooltips to the combined chart so each point can be read directly. +- Updated the plugin version to 1.1.0.1. + += 1.0.0.7 = +* Added date range filters and a combined views/unique visitors chart on the single page statistics screen. +* Added a frontend admin bar shortcut that opens statistics for the current page directly in wp-admin. +* Updated single-page CSV export to include the selected date range. + += 1.0.0.6 = +* Added true per-page unique visitor tracking stored per day for posts, pages, and tracked non-singular pages. +* Extended the single page statistics screen and CSV export with unique visitor metrics and charts. + += 1.0.0.5 = +* Expanded the single page statistics screen with more page-specific KPIs, weekday distribution, and period-over-period comparisons. +* Added CSV export for the selected page URL so the report can be downloaded directly from the single page statistics screen. + += 1.0.0.4 = +* Added a single page statistics admin page where you can enter a site URL and view statistics for that specific tracked page or post. +* Added per-page daily and monthly charts plus lifetime and recent-period summaries. + = 1.0.0 = * First public release. * Added tooltips to line, bar, and pie charts. @@ -66,5 +89,17 @@ https://ansico.dk/Ansico/Ansico-Stat-plugin == Upgrade Notice == += 1.0.0.7 = +Adds custom date ranges, a combined page chart, and a frontend admin bar shortcut for the current page report. + += 1.0.0.6 = +Adds true per-page unique visitor tracking for new data and shows those metrics on the single page statistics screen. + += 1.0.0.5 = +Adds more page-specific KPIs and CSV export on the single page statistics screen. + += 1.0.0.4 = +Adds a single page statistics screen with URL-based lookup. + = 1.0.0 = First public release.