$id, 'kind' => $kind, 'text' => $text, 'auto' => true, 'include' => true, ); }; $label = $site->conversion_label(); // Search Console. $s = isset( $data['search'] ) && ! isset( $data['search']['error'] ) ? $data['search'] : null; if ( $s ) { $c = $s['cur']; $p = $s['prev']; if ( self::moved( $c['clicks'], $p['clicks'], 20 * $scale ) ) { $up = $c['clicks'] > $p['clicks']; $add( 'clicks', $up ? self::IMPROVED : self::DECLINED, sprintf( $up /* translators: 1: percent change, 2: new value, 3: old value, 4: comparison period. */ ? __( 'Organic clicks from Google rose %1$s to %2$s (from %3$s in %4$s).', 'turtlereport' ) /* translators: 1: percent change, 2: new value, 3: old value, 4: comparison period. */ : __( 'Organic clicks from Google fell %1$s to %2$s (from %3$s in %4$s).', 'turtlereport' ), self::pct( $c['clicks'], $p['clicks'] ), Format::number( $c['clicks'] ), Format::number( $p['clicks'] ), $vs ) ); } if ( self::moved( $c['impressions'], $p['impressions'], 300 * $scale ) ) { $up = $c['impressions'] > $p['impressions']; $add( 'impressions', $up ? self::IMPROVED : self::DECLINED, $up /* translators: 1: percent change, 2: new value. */ ? sprintf( __( 'The site appeared in Google results more often: impressions rose %1$s to %2$s.', 'turtlereport' ), self::pct( $c['impressions'], $p['impressions'] ), Format::number( $c['impressions'] ) ) /* translators: 1: percent change, 2: new value. */ : sprintf( __( 'The site appeared in Google results less often: impressions fell %1$s to %2$s.', 'turtlereport' ), self::pct( $c['impressions'], $p['impressions'] ), Format::number( $c['impressions'] ) ) ); } if ( $c['impressions'] > 0 && $p['impressions'] > 0 && $c['position'] > 0 && $p['position'] > 0 && abs( $c['position'] - $p['position'] ) >= 1.0 ) { $better = $c['position'] < $p['position']; $add( 'position', $better ? self::IMPROVED : self::DECLINED, sprintf( $better /* translators: 1: old position, 2: new position. */ ? __( 'Average position in Google improved from %1$s to %2$s (lower is better).', 'turtlereport' ) /* translators: 1: old position, 2: new position. */ : __( 'Average position in Google worsened from %1$s to %2$s (lower is better).', 'turtlereport' ), number_format_i18n( $p['position'], 1 ), number_format_i18n( $c['position'], 1 ) ) ); } $up_pages = isset( $s['pages']['up'] ) ? $s['pages']['up'] : self::compact_rows( $s['winners'] ); $down_pages = isset( $s['pages']['down'] ) ? $s['pages']['down'] : self::compact_rows( $s['losers'] ); $big = max( 5, (int) round( 10 * $scale ) ); $gainers = array_slice( array_filter( $up_pages, static function ( $r ) use ( $big ) { return $r['dClicks'] >= $big; } ), 0, 3 ); $losers = array_slice( array_filter( $down_pages, static function ( $r ) use ( $big ) { return -$r['dClicks'] >= $big; } ), 0, 3 ); if ( $gainers ) { $add( 'pages_up', self::IMPROVED, sprintf( /* translators: %s: list of pages with their click change. */ _n( 'This page gained the most clicks: %s.', 'These pages gained the most clicks: %s.', count( $gainers ), 'turtlereport' ), self::list_pages( $gainers ) ) ); } if ( $losers ) { $add( 'pages_down', self::DECLINED, sprintf( /* translators: %s: list of pages with their click change. */ _n( 'This page lost the most clicks: %s.', 'These pages lost the most clicks: %s.', count( $losers ), 'turtlereport' ), self::list_pages( $losers ) ) ); $first = reset( $losers ); $add( 'review_losers', self::ACTION, sprintf( /* translators: 1: page path, 2: comparison period, e.g. the previous 28 days. */ __( 'Review the pages that lost clicks, starting with %1$s: compare its Google position and search terms with %2$s, and check whether its title, content or links changed.', 'turtlereport' ), self::page( $first['key'] ), $vs ) ); } if ( ! empty( $s['low_ctr'] ) ) { $r = $s['low_ctr'][0]; $add( 'low_ctr', self::ACTION, sprintf( /* translators: 1: page path, 2: impressions, 3: page CTR, 4: site CTR. */ __( 'Improve the Google title and description of %1$s: it was shown %2$s times on page 1 of Google, but only %3$s of searchers clicked (the site average is %4$s).', 'turtlereport' ), self::page( $r['key'] ), Format::number( $r['impressions'] ), Format::metric( 'pct', $r['ctr'] ), Format::metric( 'pct', $c['ctr'] ) ) ); } } // Conversions. $cv = isset( $data['conversions'] ) && ! isset( $data['conversions']['error'] ) ? $data['conversions'] : null; if ( $cv && 'no_events' !== $cv['status'] ) { if ( self::moved( $cv['cur'], $cv['prev'], 3 ) ) { $up = $cv['cur'] > $cv['prev']; $add( 'conversions', $up ? self::IMPROVED : self::DECLINED, sprintf( $up /* translators: 1: what the site calls conversions, 2: old value, 3: new value. */ ? __( '%1$s from organic search rose from %2$s to %3$s.', 'turtlereport' ) /* translators: 1: what the site calls conversions, 2: old value, 3: new value. */ : __( '%1$s from organic search fell from %2$s to %3$s.', 'turtlereport' ), $label, Format::number( $cv['prev'] ), Format::number( $cv['cur'] ) ) ); $clicks_held = $s && $s['cur']['clicks'] >= $s['prev']['clicks'] * 0.95; if ( ! $up && $clicks_held ) { $add( 'investigate_conversions', self::ACTION, sprintf( /* translators: %s: what the site calls conversions. */ __( 'Organic %s fell while Google clicks held steady. Check that forms, phone links and GA4 conversion tracking still work, and which landing pages lost them.', 'turtlereport' ), Text::lower_first( $label ) ) ); } } if ( $s && ! empty( $s['top_pages'] ) && $cv['cur'] > 0 ) { $floor = max( 30 * $scale, $s['cur']['clicks'] * 0.05 ); foreach ( $s['top_pages'] as $r ) { if ( $r['clicks'] >= $floor && isset( $r['leads'] ) && 0 === (int) $r['leads'] ) { $add( 'weak_conversion_page', self::ACTION, sprintf( /* translators: 1: page path, 2: clicks, 3: what the site calls conversions. */ __( 'Review %1$s: it got %2$s organic clicks but no %3$s. A clearer next step on the page (a form, button or phone number) may help.', 'turtlereport' ), self::page( $r['key'] ), Format::number( $r['clicks'] ), Text::lower_first( $label ) ) ); break; } } } if ( ! empty( $cv['missing_events'] ) ) { $add( 'missing_events', self::ACTION, sprintf( /* translators: %s: comma-separated GA4 event names. */ __( 'Check GA4 tracking for %s: no such event was recorded in the last 90 days.', 'turtlereport' ), implode( ', ', array_map( static function ( $e ) { return '"' . $e . '"'; }, $cv['missing_events'] ) ) ) ); } } // Business Profile. $b = isset( $data['business'] ) && ! isset( $data['business']['error'] ) ? $data['business'] : null; if ( $b ) { foreach ( array( 'calls' => array( __( 'Calls from the Business Profile', 'turtlereport' ), 3 ), 'web' => array( __( 'Website clicks from the Business Profile', 'turtlereport' ), 5 ), 'dirs' => array( __( 'Direction requests', 'turtlereport' ), 3 ), ) as $k => $def ) { if ( self::moved( $b['cur'][ $k ], $b['prev'][ $k ], $def[1] ) ) { $up = $b['cur'][ $k ] > $b['prev'][ $k ]; $add( 'gbp_' . $k, $up ? self::IMPROVED : self::DECLINED, sprintf( $up /* translators: 1: metric name, 2: old value, 3: new value. */ ? __( '%1$s rose from %2$s to %3$s.', 'turtlereport' ) /* translators: 1: metric name, 2: old value, 3: new value. */ : __( '%1$s fell from %2$s to %3$s.', 'turtlereport' ), $def[0], Format::number( $b['prev'][ $k ] ), Format::number( $b['cur'][ $k ] ) ) ); if ( 'calls' === $k && ! $up ) { $add( 'gbp_check', self::ACTION, __( 'Calls from the Business Profile fell: check that the listing\'s phone number, opening hours and categories are up to date.', 'turtlereport' ) ); } } } } // AI assistants. $ai = isset( $data['ai'] ) && ! isset( $data['ai']['error'] ) ? $data['ai'] : null; if ( $ai && self::moved( $ai['cur'], $ai['prev'], 10 * $scale, 0.2 ) ) { $up = $ai['cur'] > $ai['prev']; $add( 'ai', $up ? self::IMPROVED : self::DECLINED, sprintf( $up /* translators: 1: old value, 2: new value. */ ? __( 'Visits from AI assistants such as ChatGPT rose from %1$s to %2$s.', 'turtlereport' ) /* translators: 1: old value, 2: new value. */ : __( 'Visits from AI assistants such as ChatGPT fell from %1$s to %2$s.', 'turtlereport' ), Format::number( $ai['prev'] ), Format::number( $ai['cur'] ) ) ); } // GA4 traffic: the first two metrics that moved. $t = isset( $data['traffic'] ) && ! isset( $data['traffic']['error'] ) ? $data['traffic'] : null; if ( $t ) { $n = 0; foreach ( $t['metrics'] as $m ) { if ( $n >= 2 ) { break; } $moved = 'pct' === $m['format'] ? abs( $m['cur'] - $m['prev'] ) >= 0.03 && $m['prev'] > 0 : self::moved( $m['cur'], $m['prev'], 'int' === $m['format'] ? 30 * $scale : 0.0001 ); if ( ! $moved ) { continue; } $up = $m['cur'] > $m['prev']; $good = $m['lower'] ? ! $up : $up; $add( 'traffic_' . $m['key'], $good ? self::IMPROVED : self::DECLINED, sprintf( $up /* translators: 1: metric name, 2: old value, 3: new value. */ ? __( '%1$s rose from %2$s to %3$s.', 'turtlereport' ) /* translators: 1: metric name, 2: old value, 3: new value. */ : __( '%1$s fell from %2$s to %3$s.', 'turtlereport' ), $m['label'], Format::metric( $m['format'], $m['prev'] ), Format::metric( $m['format'], $m['cur'] ) ) ); ++$n; } } return self::cap( self::prioritise( $items ) ); } /** * Orders items so the outcomes clients care most about come first: * clicks and conversions, then calls and AI visits, then the rest. * * @param array[] $items Items in rule order. * @return array[] */ private static function prioritise( array $items ) { $rank = array( 'clicks' => 1, 'conversions' => 2, 'investigate_conversions' => 2, 'gbp_calls' => 3, 'gbp_check' => 3, 'missing_events' => 3, 'review_losers' => 4, 'pages_up' => 5, 'pages_down' => 5, 'weak_conversion_page' => 5, 'ai' => 6, 'low_ctr' => 6, 'keywords_down' => 7, 'opportunity' => 7, ); foreach ( $items as $i => $item ) { $items[ $i ]['_r'] = ( isset( $rank[ $item['id'] ] ) ? $rank[ $item['id'] ] : 9 ) * 100 + $i; } usort( $items, static function ( $a, $b ) { return $a['_r'] <=> $b['_r']; } ); foreach ( $items as $i => $item ) { unset( $items[ $i ]['_r'] ); } return $items; } /** * Keeps at most MAX_PER_KIND items of each kind, in priority order. * * @param array[] $items Items. * @return array[] */ private static function cap( array $items ) { $count = array(); $out = array(); foreach ( $items as $item ) { $k = $item['kind']; if ( ( isset( $count[ $k ] ) ? $count[ $k ] : 0 ) < self::MAX_PER_KIND ) { $out[] = $item; $count[ $k ] = ( isset( $count[ $k ] ) ? $count[ $k ] : 0 ) + 1; } } return $out; } /** * Whether a value moved enough to mention: at least $min_abs and $min_rel. * * @param float $cur Current value. * @param float $prev Previous value. * @param float $min_abs Smallest absolute change. * @param float $min_rel Smallest relative change (0.1 = 10%). * @return bool */ public static function moved( $cur, $prev, $min_abs, $min_rel = 0.1 ) { $cur = (float) $cur; $prev = (float) $prev; $diff = abs( $cur - $prev ); if ( $diff < $min_abs || $diff <= 0 ) { return false; } return $prev <= 0 ? true : $diff / $prev >= $min_rel; } /** * Percent change as text, e.g. "18%". * * @param float $cur Current. * @param float $prev Previous. * @return string */ private static function pct( $cur, $prev ) { if ( (float) $prev <= 0 ) { return '100%'; } $v = abs( ( (float) $cur - (float) $prev ) / (float) $prev * 100 ); return number_format_i18n( $v, $v < 10 ? 1 : 0 ) . '%'; } /** * Signed whole number, e.g. "+12" or "−5". * * @param float $n Number. * @return string */ private static function signed( $n ) { return ( $n > 0 ? '+' : ( $n < 0 ? '−' : '±' ) ) . Format::number( abs( $n ) ); } /** * A page URL shortened to its path. * * @param string $url URL. * @return string */ public static function page( $url ) { $path = wp_parse_url( (string) $url, PHP_URL_PATH ); $path = is_string( $path ) && '' !== $path ? rawurldecode( $path ) : (string) $url; return Text::truncate( $path, 70 ); } /** * "path (+12), path (+8)". * * @param array[] $rows Rows with key and dClicks. * @return string */ private static function list_pages( array $rows ) { return implode( ', ', array_map( static function ( $r ) { return self::page( $r['key'] ) . ' (' . self::signed( $r['dClicks'] ) . ')'; }, $rows ) ); } /** * Converts compact summary rows to joined rows for insights. * * @param array[] $rows Rows with page, clicks and delta. * @return array[] */ private static function compact_rows( array $rows ) { return array_map( static function ( $r ) { return array( 'key' => $r['page'], 'clicks' => $r['clicks'], 'dClicks' => $r['delta'], ); }, $rows ); } /** * Words for the comparison period: "August 2026", "the same period last year" or "the previous 7 days". * * @param array $period Period. * @return string */ public static function versus( array $period ) { $ps = $period['ps']; $pe = $period['pe']; if ( substr( $ps, 8 ) === '01' && gmdate( 'Y-m-t', strtotime( $ps . ' 12:00:00 UTC' ) ) === $pe ) { return wp_date( 'F Y', strtotime( $ps . ' 12:00:00 UTC' ), new \DateTimeZone( 'UTC' ) ); } if ( \TurtleReport\Support\Periods::shift_year( $period['s'], -1 ) === $ps ) { return __( 'the same period last year', 'turtlereport' ); } $days = \TurtleReport\Reports\Summary::days( $ps, $pe ); /* translators: %d: number of days. */ return sprintf( _n( 'the previous %d day', 'the previous %d days', $days, 'turtlereport' ), $days ); } /** * Cleans insights posted from the review screen. * * @param array $posted Items with id, kind, text and include. * @return array[] */ public static function from_form( array $posted ) { $out = array(); foreach ( $posted as $row ) { if ( ! is_array( $row ) ) { continue; } $text = isset( $row['text'] ) && is_scalar( $row['text'] ) ? trim( sanitize_textarea_field( (string) $row['text'] ) ) : ''; $kind = isset( $row['kind'] ) && in_array( $row['kind'], array( self::IMPROVED, self::DECLINED, self::ACTION ), true ) ? $row['kind'] : self::ACTION; $id = isset( $row['id'] ) && is_scalar( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : ''; if ( '' === $text ) { continue; } $out[] = array( 'id' => '' !== $id ? $id : 'custom', 'kind' => $kind, 'text' => Text::substr( $text, 0, self::MAX_LENGTH ), 'auto' => ! empty( $row['auto'] ) && is_scalar( $row['auto'] ), 'include' => ! empty( $row['include'] ) && is_scalar( $row['include'] ), ); if ( count( $out ) >= 30 ) { break; } } return $out; } /** * Included items grouped by kind, for templates. Emails and PDFs show * the first three of each kind. * * @param array[] $items Items. * @return array Kind => texts. */ public static function grouped( array $items ) { $out = array( self::IMPROVED => array(), self::DECLINED => array(), self::ACTION => array(), ); // Lines an administrator wrote come first, so they survive the three-per-heading limit. foreach ( array( false, true ) as $auto ) { foreach ( $items as $item ) { if ( (bool) ! empty( $item['auto'] ) === $auto && ! empty( $item['include'] ) && isset( $out[ $item['kind'] ] ) && '' !== (string) $item['text'] ) { $out[ $item['kind'] ][] = (string) $item['text']; } } } return $out; } /** * Headings for each kind. * * @return array */ public static function headings() { return array( self::IMPROVED => __( 'What improved', 'turtlereport' ), self::DECLINED => __( 'What declined', 'turtlereport' ), self::ACTION => __( 'Suggested next steps', 'turtlereport' ), ); } }