summary = $summary; foreach ( $reports as $r ) { $this->reports[ $r->id() ] = $r; } $this->monitor = $monitor; } /** * One client in detail. * * @param Profile $site Client. * @param array $period Period (s, e, ps, pe). * @param string|null $issue Alert ID to focus on. * @return array{data:array,basis:array} */ public function site( Profile $site, array $period, $issue = null ) { $missing = array(); $sources = array(); $data = array( 'client' => $site->display_name(), 'website' => self::website( $site ), 'period' => self::period_data( $period ), ); $summary = $this->summary->build( $site, $period, false ); $this->add_sections( $data, $summary, $site, $period, $missing, $sources, true ); if ( $issue && $this->monitor ) { $alert = $this->monitor->store()->find( $site->id, $issue ); if ( $alert ) { $data['issue'] = self::issue( Presenter::present( $alert, $site ), true ); } } $alerts = $this->alerts( $site, false ); if ( null !== $alerts ) { // The other open issues, briefly; the one asked about is already in full above. $data['open_issues'] = array_values( array_filter( $alerts, static function ( $a ) use ( $issue ) { return $a['id'] !== $issue; } ) ); $sources[] = __( 'Client Monitor', 'turtlereport' ); } return array( 'data' => $data, 'basis' => array( 'period' => self::period_label( $period ), 'sources' => array_values( array_unique( $sources ) ), 'missing' => array_values( array_unique( $missing ) ), 'metrics' => self::tiles( $summary ), 'clients' => 1, ), ); } /** * Every client the person may see, briefly. Clients with open issues * come first; report data loads until a time budget runs out. * * @param Profile[] $sites Clients. * @param array $period Period. * @return array{data:array,basis:array} */ public function agency( array $sites, array $period ) { $rows = array(); $missing = array(); $sources = array(); $issues = array(); foreach ( $sites as $site ) { $a = $this->alerts( $site, false ); $issues[ $site->id ] = $a ? $a : array(); } usort( $sites, static function ( $x, $y ) use ( $issues ) { $high = static function ( $id ) use ( $issues ) { return count( array_filter( $issues[ $id ], static function ( $i ) { return 'high' === $i['severity'] && 'active' === $i['status']; } ) ); }; $severity = $high( $y->id ) <=> $high( $x->id ); if ( 0 !== $severity ) { return $severity; } $count = count( $issues[ $y->id ] ) <=> count( $issues[ $x->id ] ); return 0 !== $count ? $count : strnatcasecmp( $x->display_name(), $y->display_name() ); } ); $left = count( $sites ) - self::MAX_SITES; $sites = array_slice( $sites, 0, self::MAX_SITES ); $start = microtime( true ); $late = 0; foreach ( $sites as $site ) { $row = array( 'client' => $site->display_name(), 'website' => self::website( $site ), ); if ( microtime( true ) - $start < self::AGENCY_BUDGET ) { $summary = $this->summary->build( $site, $period, false ); $miss = array(); $this->add_sections( $row, $summary, $site, $period, $miss, $sources, false ); foreach ( $miss as $m ) { $missing[] = $site->display_name() . ': ' . $m; } } else { ++$late; $row['numbers'] = 'not loaded'; } if ( null !== $this->alerts( $site, false ) ) { $row['open_issues'] = $issues[ $site->id ]; $sources[] = __( 'Client Monitor', 'turtlereport' ); } $rows[] = $row; } if ( $late ) { /* translators: %d: number of clients. */ $missing[] = sprintf( _n( 'Report numbers for %d client didn\'t load in time; only its Client Monitor issues were used.', 'Report numbers for %d clients didn\'t load in time; only their Client Monitor issues were used.', $late, 'turtlereport' ), $late ); } if ( $left > 0 ) { /* translators: %d: number of clients. */ $missing[] = sprintf( _n( '%d more client wasn\'t included. Ask about it on its own.', '%d more clients weren\'t included. Ask about them one at a time.', $left, 'turtlereport' ), $left ); } if ( ! $this->monitor ) { $missing[] = __( 'The Client Monitor is switched off, so there is no list of open issues.', 'turtlereport' ); } return array( 'data' => array( 'period' => self::period_data( $period ), 'clients' => $rows, ), 'basis' => array( 'period' => self::period_label( $period ), 'sources' => array_values( array_unique( $sources ) ), 'missing' => array_values( array_unique( $missing ) ), 'metrics' => array(), 'clients' => count( $rows ), ), ); } /** * Adds the report sections to a client's data. * * @param array $data Data (changed). * @param array $summary Summary output. * @param Profile $site Client. * @param array $period Period. * @param string[] $missing Missing data (changed). * @param string[] $sources Sources used (changed). * @param bool $detail Pages and keywords too. * @return void */ private function add_sections( array &$data, array $summary, Profile $site, array $period, array &$missing, array &$sources, $detail ) { $names = array( 'search' => __( 'Search Console', 'turtlereport' ), 'conversions' => __( 'GA4 conversions', 'turtlereport' ), 'traffic' => __( 'GA4 traffic', 'turtlereport' ), 'ai' => __( 'AI assistant traffic (GA4)', 'turtlereport' ), 'business' => __( 'Google Business Profile', 'turtlereport' ), ); foreach ( $detail ? array( 'search', 'conversions', 'business' ) : array() as $key ) { $wanted = array( 'search' => Profile::SECTION_SEARCH, 'conversions' => Profile::SECTION_CONVERSIONS, 'business' => Profile::SECTION_BUSINESS, ); if ( ! $site->wants( $wanted[ $key ] ) ) { /* translators: %s: data source. */ $missing[] = sprintf( __( '%s isn\'t set up for this client.', 'turtlereport' ), $names[ $key ] ); } } foreach ( $summary as $key => $sec ) { if ( ! isset( $names[ $key ] ) || ! is_array( $sec ) ) { continue; } if ( isset( $sec['error'] ) ) { /* translators: 1: data source, 2: error. */ $missing[] = sprintf( __( '%1$s couldn\'t be read: %2$s', 'turtlereport' ), $names[ $key ], (string) $sec['error'] ); continue; } if ( isset( $sec['status'] ) && in_array( $sec['status'], array( 'no_data', 'no_events' ), true ) ) { /* translators: %s: data source. */ $missing[] = sprintf( __( '%s has no data for this client in this period.', 'turtlereport' ), $names[ $key ] ); continue; } if ( ! $detail && ! in_array( $key, array( 'search', 'conversions', 'business' ), true ) ) { continue; // Not sent for "all clients", so not listed as a source either. } $sources[] = $names[ $key ]; if ( ! $detail ) { // For a question about every client, only the headline numbers. if ( 'search' === $key ) { $data['google_clicks'] = self::pair( $sec['cur']['clicks'], $sec['prev']['clicks'] ); $data['google_impressions'] = self::pair( $sec['cur']['impressions'], $sec['prev']['impressions'] ); } elseif ( 'conversions' === $key ) { $data[ 'ga4_' . sanitize_key( (string) $sec['label'] ) ] = self::pair( $sec['cur'], $sec['prev'] ); } elseif ( 'business' === $key ) { $data['business_profile_calls'] = self::pair( isset( $sec['cur']['calls'] ) ? $sec['cur']['calls'] : 0, isset( $sec['prev']['calls'] ) ? $sec['prev']['calls'] : 0 ); } continue; } switch ( $key ) { case 'search': $data['search_console'] = self::search( $sec, $this->keywords( $site, $period ), true ); break; case 'conversions': $data['ga4_conversions'] = self::conversions( $sec, $detail ); break; case 'traffic': $data['ga4_traffic'] = self::traffic( $sec ); break; case 'ai': $data['ai_assistant_visits'] = array( 'sessions' => self::pair( $sec['cur'], $sec['prev'] ), 'conversions' => self::pair( isset( $sec['conv'] ) ? $sec['conv'] : 0, isset( $sec['conv_prev'] ) ? $sec['conv_prev'] : 0 ), ); break; case 'business': $data['business_profile'] = self::business( $sec ); break; } } } /** * Search Console section. * * @param array $s Section. * @param array|null $keywords Keyword movers, or null. * @param bool $detail Include pages. * @return array */ private static function search( array $s, $keywords, $detail ) { $out = array( 'clicks' => self::pair( $s['cur']['clicks'], $s['prev']['clicks'] ), 'impressions' => self::pair( $s['cur']['impressions'], $s['prev']['impressions'] ), 'ctr_percent' => self::pair( round( $s['cur']['ctr'] * 100, 2 ), round( $s['prev']['ctr'] * 100, 2 ), false ), 'avg_position' => self::pair( round( $s['cur']['position'], 1 ), round( $s['prev']['position'], 1 ), false ), ); if ( $detail ) { $page = static function ( $r ) { return array( self::path( $r['page'] ), (int) round( $r['clicks'] - $r['delta'] ), (int) round( $r['clicks'] ) ); }; $moved = static function ( $r ) { return abs( (float) $r['delta'] ) >= 2; }; $cols = array( 'page', 'clicks_before', 'clicks_now' ); $up = array_values( array_filter( (array) $s['winners'], $moved ) ); $down = array_values( array_filter( (array) $s['losers'], $moved ) ); if ( $up ) { $out['pages_gaining_clicks'] = array( 'columns' => $cols, 'rows' => array_map( $page, $up ), ); } if ( $down ) { $out['pages_losing_clicks'] = array( 'columns' => $cols, 'rows' => array_map( $page, $down ), ); } } if ( $keywords ) { $out = array_merge( $out, $keywords ); } return $out; } /** * The keywords that gained and lost the most clicks. * * @param Profile $site Client. * @param array $period Period. * @return array|null */ private function keywords( Profile $site, array $period ) { if ( ! isset( $this->reports['search-console'] ) ) { return null; } try { $q = function ( $s, $e ) use ( $site ) { return (array) $this->reports['search-console']->run( $site, array( 'segment' => '', 'dimension' => 'query', 'start' => $s, 'end' => $e, ) ); }; $rows = Summary::join_rows( $q( $period['s'], $period['e'] ), $q( $period['ps'], $period['pe'] ) ); } catch ( \Throwable $e ) { return null; } $fmt = static function ( $r ) { return array( $r['key'], (int) round( $r['pClicks'] ), (int) round( $r['clicks'] ), (int) round( $r['pImpr'] ), (int) round( $r['impressions'] ), null === $r['pPos'] || ! $r['inPrev'] ? null : round( $r['pPos'], 1 ), $r['inCur'] ? round( (float) $r['position'], 1 ) : null, ); }; $up = array_values( array_filter( $rows, static function ( $r ) { return $r['dClicks'] >= 2; } ) ); $down = array_values( array_filter( $rows, static function ( $r ) { return $r['dClicks'] <= -2 || ( $r['pImpr'] - $r['impressions'] ) >= 100; } ) ); usort( $up, static function ( $a, $b ) { return $b['dClicks'] <=> $a['dClicks']; } ); usort( $down, static function ( $a, $b ) { $clicks = $a['dClicks'] <=> $b['dClicks']; return 0 !== $clicks ? $clicks : ( ( $b['pImpr'] - $b['impressions'] ) <=> ( $a['pImpr'] - $a['impressions'] ) ); } ); $cols = array( 'keyword', 'clicks_before', 'clicks_now', 'impressions_before', 'impressions_now', 'position_before', 'position_now' ); return array( 'keywords_gaining' => array( 'columns' => $cols, 'rows' => array_map( $fmt, array_slice( $up, 0, self::ROWS_UP ) ), ), 'keywords_losing' => array( 'columns' => $cols, 'rows' => array_map( $fmt, array_slice( $down, 0, self::ROWS ) ), ), ); } /** * Conversions section. * * @param array $c Section. * @param bool $detail Per event. * @return array */ private static function conversions( array $c, $detail ) { $out = array( 'label' => (string) $c['label'], 'total' => self::pair( $c['cur'], $c['prev'] ), ); if ( count( (array) $c['groups'] ) > 1 ) { $out['groups'] = array(); foreach ( $c['groups'] as $g ) { $out['groups'][ (string) $g['label'] ] = self::pair( $g['cur'], $g['prev'] ); } } if ( $detail && ! empty( $c['by_event'] ) ) { $out['by_event'] = array(); foreach ( array_slice( (array) $c['events'], 0, 12 ) as $ev ) { $out['by_event'][ (string) $ev ] = self::pair( isset( $c['by_event']['cur'][ $ev ] ) ? $c['by_event']['cur'][ $ev ] : 0, isset( $c['by_event']['prev'][ $ev ] ) ? $c['by_event']['prev'][ $ev ] : 0 ); } } if ( ! empty( $c['missing_events'] ) ) { $out['events_never_recorded_in_90_days'] = array_values( (array) $c['missing_events'] ); } return $out; } /** * Traffic section. * * @param array $t Section. * @return array */ private static function traffic( array $t ) { $out = array( 'scope' => (string) $t['scope'] ); foreach ( (array) $t['metrics'] as $m ) { $label = isset( $m[0] ) ? (string) $m[0] : (string) $m['key']; $ratio = isset( $m[1] ) && 'pct' === $m[1]; $out[ $label ] = self::pair( $ratio ? round( $m['cur'] * 100, 1 ) : $m['cur'], $ratio ? round( $m['prev'] * 100, 1 ) : $m['prev'], ! $ratio ); } return $out; } /** * Business Profile section. * * @param array $b Section. * @return array */ private static function business( array $b ) { $out = array(); foreach ( array( 'calls' => 'calls', 'web' => 'website_clicks', 'dirs' => 'direction_requests', 'views' => 'profile_views', ) as $k => $name ) { $out[ $name ] = self::pair( isset( $b['cur'][ $k ] ) ? $b['cur'][ $k ] : 0, isset( $b['prev'][ $k ] ) ? $b['prev'][ $k ] : 0 ); } if ( ! empty( $b['last_date'] ) ) { $out['data_until'] = (string) $b['last_date']; } return $out; } /** * The client's open issues from the Client Monitor, or null when it's off. * * @param Profile $site Client. * @param bool $detail With insights and checks. * @return array[]|null */ private function alerts( Profile $site, $detail ) { if ( ! $this->monitor || ! $this->monitor->enabled() ) { return null; } $out = array(); foreach ( $this->monitor->store()->site( $site->id )['alerts'] as $alert ) { $state = AlertStore::state( $alert ); if ( AlertStore::RESOLVED === $state ) { continue; } $out[] = self::issue( Presenter::present( $alert, $site ), $detail ); } return $out; } /** * An issue, for the model. * * @param array $p Presented alert. * @param bool $detail With insights, checks and tables. * @return array */ private static function issue( array $p, $detail ) { $out = array( 'id' => $p['id'], 'type' => $p['kind'], 'severity' => $p['severity'], 'status' => $p['state'], 'headline' => $p['headline'], 'page' => $p['url'], 'numbers' => array_map( static function ( $m ) { return $m['label'] . ': ' . $m['value'] . ( '' !== (string) $m['change'] ? ' (' . $m['change'] . ')' : '' ); }, $p['metrics'] ), ); if ( $detail ) { $out['what_the_data_suggests'] = $p['insights']; $out['recommended_checks'] = $p['checks']; $out['confidence'] = $p['confidence']['text']; foreach ( array_slice( $p['tables'], 0, 1 ) as $t ) { $out['tables'][] = array( 'title' => $t['title'], 'columns' => $t['cols'], 'rows' => array_slice( $t['rows'], 0, 6 ), ); } } if ( '' === (string) $out['page'] ) { unset( $out['page'] ); } return $out; } /** * Headline numbers shown under an answer. * * @param array $summary Summary output. * @return array[] */ private static function tiles( array $summary ) { $out = array(); if ( isset( $summary['search']['cur'] ) && empty( $summary['search']['error'] ) ) { $s = $summary['search']; $out[] = self::tile( __( 'Google clicks', 'turtlereport' ), $s['cur']['clicks'], $s['prev']['clicks'] ); $out[] = self::tile( __( 'Impressions', 'turtlereport' ), $s['cur']['impressions'], $s['prev']['impressions'] ); } if ( isset( $summary['conversions']['cur'] ) && empty( $summary['conversions']['error'] ) && 'no_events' !== $summary['conversions']['status'] ) { $out[] = self::tile( (string) $summary['conversions']['label'], $summary['conversions']['cur'], $summary['conversions']['prev'] ); } if ( isset( $summary['business']['cur'] ) && empty( $summary['business']['error'] ) ) { $out[] = self::tile( __( 'Calls (Business Profile)', 'turtlereport' ), $summary['business']['cur']['calls'], $summary['business']['prev']['calls'] ); } return $out; } /** * A headline number. * * @param string $label Label. * @param float $now Now. * @param float $prev Before. * @return array */ private static function tile( $label, $now, $prev ) { $change = $prev > 0 ? round( ( $now - $prev ) / $prev * 100 ) : null; return array( 'label' => $label, 'value' => number_format_i18n( round( (float) $now ) ), 'change' => null === $change ? '' : ( $change > 0 ? '+' : '' ) . number_format_i18n( $change ) . '%', 'tone' => null === $change || abs( $change ) < 1 ? 'flat' : ( $change < 0 ? 'bad' : 'good' ), ); } /** * Before, now and the change. * * @param float $now Now. * @param float $prev Before. * @param bool $change Add the percentage change. * @return array */ private static function pair( $now, $prev, $change = true ) { $now = round( (float) $now, 2 ); $prev = round( (float) $prev, 2 ); // A short list instead of named fields: the same facts in about half the tokens. $out = array( $prev == (int) $prev ? (int) $prev : $prev, // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- Whole numbers read better without ".0". $now == (int) $now ? (int) $now : $now, // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- As above. ); if ( $change ) { $out[] = $prev > 0 ? round( ( $now - $prev ) / $prev * 100 ) : null; } return $out; } /** * Dates for the model. * * @param array $p Period. * @return array */ private static function period_data( array $p ) { return array( 'current' => $p['s'] . ' to ' . $p['e'], 'previous' => $p['ps'] . ' to ' . $p['pe'], 'numbers' => '[previous, current, % change]; % change is null when previous was 0. GA4 conversions and sessions are organic search only.', ); } /** * "15 Sep – 12 Oct vs 18 Aug – 14 Sep". * * @param array $p Period. * @return string */ public static function period_label( array $p ) { $f = static function ( $ymd ) { return wp_date( 'j M Y', strtotime( $ymd . ' 12:00:00 UTC' ), new \DateTimeZone( 'UTC' ) ); }; /* translators: 1: current dates, 2: earlier dates. */ return sprintf( __( '%1$s vs %2$s', 'turtlereport' ), $f( $p['s'] ) . ' – ' . $f( $p['e'] ), $f( $p['ps'] ) . ' – ' . $f( $p['pe'] ) ); } /** * The website's address from its Search Console property. * * @param Profile $site Client. * @return string */ private static function website( Profile $site ) { $prop = (string) $site->gsc_site; if ( 0 === strpos( $prop, 'sc-domain:' ) ) { return substr( $prop, 10 ); } $host = wp_parse_url( $prop, PHP_URL_HOST ); return $host ? (string) $host : ''; } /** * A page URL as its path. * * @param string $url URL. * @return string */ private static function path( $url ) { $path = wp_parse_url( (string) $url, PHP_URL_PATH ); return $path ? (string) $path : (string) $url; } }