report->summary( $range, $from, $to, $compare, Saddle_Analytics_Dimensions::filters_from( $input ) ); $out = array( 'window' => self::window( $range, $from, $to ), 'current' => self::kpis( $cards ), 'previous' => $compare && ! empty( $cards['previous'] ) ? self::kpis( $cards['previous'] ) : null, 'change' => null, 'online_now' => (int) $cards['online'], 'identity_mode' => Saddle_Analytics_Identity::cookie_mode() ? 'cookie' : 'cookieless', 'notes' => self::identity_notes(), ); if ( $compare && is_array( $cards['delta'] ?? null ) ) { $d = $cards['delta']; $out['change'] = array( 'visitors' => $d['visitors'], 'visits' => $d['visits'], 'pageviews' => $d['pageviews'], 'bounce_rate' => $d['bounce_rate'], 'avg_visit_seconds' => $d['duration'], ); } return $out; } public function breakdown( $input ) { $input = (array) $input; $dimension = Saddle_Analytics_Dimensions::get( (string) ( $input['dimension'] ?? '' ) ); if ( ! $dimension ) { return new WP_Error( 'saddle_analytics_bad_dimension', __( 'Unknown dimension.', 'saddle-analytics' ) ); } list( $range, $from, $to ) = self::window_args( $input ); $error = Saddle_Analytics_Rest::window_error_for( $from, $to ); if ( $error ) { return $error; } $metric = (string) ( $input['metric'] ?? Saddle_Analytics_Metric::VISITORS ); $limit = max( 1, min( Saddle_Analytics_Ability_Schemas::MAX_BREAKDOWN, (int) ( $input['limit'] ?? 10 ) ) ); $filters = Saddle_Analytics_Dimensions::filters_from( $input ); $rows = $this->report->table( $dimension, $metric, $range, $filters, $limit, $from, $to ); $out = array( 'dimension' => $dimension->key, 'metric' => $metric, 'window' => self::window( $range, $from, $to ), 'rows' => array_map( static fn( array $r ) => array( 'value' => Saddle_Analytics_Dimensions::slug( $dimension, $r['value'] ), 'label' => self::label( $dimension, $r['value'] ), 'visitors' => (int) $r['visitors'], 'pageviews' => (int) $r['pageviews'], ), $rows ), 'notes' => array(), ); if ( 'channel' === $dimension->key ) { $out['notes'][] = 'The ai channel counts visits that arrived with an AI assistant as the referrer or utm_source (ChatGPT, Claude, Perplexity, Gemini, Copilot…). AI visits that send no referrer are counted as direct — no tool can recover them.'; } if ( 'visitors' === $dimension->table ) { $out['notes'][] = 'Source and audience rows are windowed by the visitor’s first visit, so a lifetime total can exceed a short window’s.'; } if ( 'sources' === $dimension->group || isset( $filters['channel'] ) || isset( $filters['referrer'] ) || isset( $filters['campaign'] ) ) { $out['notes'][] = 'last' === Saddle_Analytics_Dimensions::attribution() ? 'Sources credit each visitor’s latest visit that came with a referrer (the site’s attribution setting is last-touch).' : 'Sources credit each visitor’s first visit (the site’s attribution setting is first-touch).'; } return $out; } public function timeseries( $input ) { $input = (array) $input; list( $range, $from, $to ) = self::window_args( $input ); $error = Saddle_Analytics_Rest::window_error_for( $from, $to ); if ( $error ) { return $error; } $series = $this->report->timeseries( $range, $from, $to, (bool) ( $input['compare'] ?? false ), Saddle_Analytics_Dimensions::filters_from( $input ) ); $points = static fn( array $list ) => array_map( static fn( array $p ) => array( 'start' => gmdate( 'c', (int) $p['ts'] ), 'visitors' => (int) $p['visitors'], 'pageviews' => (int) $p['pageviews'], ), $list ); return array( 'interval' => $series['interval'], 'points' => $points( $series['points'] ), 'previous' => isset( $series['previous'] ) && is_array( $series['previous'] ) ? $points( $series['previous'] ) : null, 'notes' => array( 'Each point counts the distinct visitors in that bucket, so points do not add up to the window’s unique visitors. Page views do add up.' ), ); } public static function check_setup() { global $wpdb; $state = Saddle_Analytics_Migrate::state(); $rows = array(); $tables = Saddle_Analytics_Install::tables(); foreach ( $tables as $name => $table ) { $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); $rows[ $name ] = $exists ? (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ) : null; } $last = null !== $rows['hits'] ? $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(ts) FROM %i WHERE event_type = 1', $tables['hits'] ) ) : null; $day = null !== $rows['hits'] ? (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE event_type = 1 AND ts >= %s', $tables['hits'], gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) ) ) : 0; $notes = array(); if ( Saddle_Analytics_Migrate::blocking() ) { $notes[] = 'Tracking is paused until the data migration finishes (wp saddle-analytics migrate).'; } elseif ( ! $day ) { $notes[] = 'No page views in the last 24 hours. If the site had visitors, a full-page cache may be serving pages without the tracker, or the REST API may be blocked.'; } return array( 'version' => SADDLE_ANALYTICS_VERSION, 'identity_mode' => Saddle_Analytics_Identity::cookie_mode() ? 'cookie' : 'cookieless', 'attribution' => Saddle_Analytics_Dimensions::attribution(), 'track_admins' => (bool) Saddle_Analytics_Settings::get( 'track_admins' ), 'tracking' => Saddle_Analytics_Migrate::blocking() ? 'paused for data migration' : 'on', 'migration' => isset( $state['status'] ) ? (string) $state['status'] : null, 'rows' => $rows, 'last_pageview' => $last ? gmdate( 'c', (int) strtotime( $last . ' UTC' ) ) : null, 'pageviews_24h' => $day, 'retention_days' => Saddle_Analytics_Cron::retention_days(), 'prune_scheduled' => (bool) wp_next_scheduled( Saddle_Analytics_Cron::HOOK ), 'notes' => $notes, ); } /** @return array{0:string,1:?string,2:?string} */ private static function window_args( array $input ): array { $range = in_array( $input['range'] ?? '30d', Saddle_Analytics_Report::RANGES, true ) ? (string) ( $input['range'] ?? '30d' ) : '30d'; $from = isset( $input['from'] ) && '' !== $input['from'] ? (string) $input['from'] : null; $to = isset( $input['to'] ) && '' !== $input['to'] ? (string) $input['to'] : null; return array( $range, $from, $to ); } private static function window( string $range, ?string $from, ?string $to ): array { $w = Saddle_Analytics_Window::resolve( $range, $from, $to, time() ); $iso = static fn( ?string $dt ) => null === $dt ? null : gmdate( 'c', (int) strtotime( $dt . ' UTC' ) ); return array( 'range' => null === $from ? $range : 'custom', 'start' => $iso( $w['start'] ), 'end' => $iso( $w['end'] ), ); } private static function kpis( array $c ): array { return array( 'visitors' => (int) $c['visitors'], 'visits' => (int) $c['visits'], 'pageviews' => (int) $c['pageviews'], 'bounce_rate' => (float) $c['bounce_rate'], 'avg_visit_seconds' => (int) $c['duration'], ); } private static function label( Saddle_Analytics_Dimension $d, $value ): string { if ( null === $value || '' === $value ) { return '(none)'; } return match ( $d->format ) { 'channel' => Saddle_Analytics_Channel::label( (int) $value ), 'device' => Saddle_Analytics_Device::label( (int) $value ), default => (string) $value, }; } private static function identity_notes(): array { return Saddle_Analytics_Identity::cookie_mode() ? array() : array( 'Cookieless mode: a visitor is recognised for one UTC day, so a multi-day unique-visitor count is the sum of daily uniques.' ); } }