0 ) { $score = ( $cur - $prev ) / $prev * 100; if ( $lower ) { $score = -$score; } } $rows[] = array( 'section' => $section, 'label' => $label, 'cur' => $cur_text, 'prev' => $prev_text, 'change' => Format::delta( $cur, $prev, $kind, $lower ), 'score' => $score, 'sub' => $sub, 'value' => $cur, // Raw numbers, for the PDF's arrows and bars. 'before' => $prev, ); }; if ( isset( $data['search']['cur'], $data['search']['prev'] ) && ! isset( $data['search']['error'] ) ) { $c = $data['search']['cur']; $p = $data['search']['prev']; $s = __( 'Google search', 'turtlereport' ); $add( $s, __( 'Google clicks', 'turtlereport' ), $c['clicks'], $p['clicks'], Format::number( $c['clicks'] ), Format::number( $p['clicks'] ), 'rel' ); $add( $s, __( 'Times seen in Google', 'turtlereport' ), $c['impressions'], $p['impressions'], Format::number( $c['impressions'] ), Format::number( $p['impressions'] ), 'rel' ); $add( $s, __( 'Click-through rate', 'turtlereport' ), $c['ctr'], $p['ctr'], Format::metric( 'pct', $c['ctr'] ), Format::metric( 'pct', $p['ctr'] ), 'pts' ); if ( $c['position'] > 0 || $p['position'] > 0 ) { $add( $s, __( 'Average position', 'turtlereport' ), $c['position'], $p['position'], number_format_i18n( (float) $c['position'], 1 ), $p['position'] > 0 ? number_format_i18n( (float) $p['position'], 1 ) : '–', 'pos' ); } } if ( isset( $data['conversions']['cur'] ) && ! isset( $data['conversions']['error'] ) ) { $cv = $data['conversions']; /* translators: %s: what the site calls its conversions, e.g. Leads. */ $s = sprintf( __( '%s from organic search', 'turtlereport' ), $site->conversion_label() ); $add( $s, $site->conversion_label(), $cv['cur'], $cv['prev'], Format::number( $cv['cur'] ), Format::number( $cv['prev'] ), 'rel' ); $groups = isset( $cv['groups'] ) ? (array) $cv['groups'] : array(); if ( count( $groups ) > 1 ) { foreach ( $groups as $g ) { $add( $s, $g['label'], $g['cur'], $g['prev'], Format::number( $g['cur'] ), Format::number( $g['prev'] ), 'abs', false, true ); } } } if ( isset( $data['traffic']['metrics'] ) && ! isset( $data['traffic']['error'] ) ) { $s = __( 'Website traffic', 'turtlereport' ); foreach ( (array) $data['traffic']['metrics'] as $m ) { $pct = 'pct' === $m['format']; $add( $s, $m['label'], $m['cur'], $m['prev'], Format::metric( $m['format'], $m['cur'] ), Format::metric( $m['format'], $m['prev'] ), $pct ? 'pts' : 'rel', ! empty( $m['lower'] ) ); } } if ( isset( $data['ai']['cur'] ) && ! isset( $data['ai']['error'] ) ) { $s = __( 'AI assistants', 'turtlereport' ); $add( $s, __( 'Visits from AI assistants', 'turtlereport' ), $data['ai']['cur'], $data['ai']['prev'], Format::number( $data['ai']['cur'] ), Format::number( $data['ai']['prev'] ), 'rel' ); if ( isset( $data['ai']['conv'] ) && ( $data['ai']['conv'] > 0 || $data['ai']['conv_prev'] > 0 ) ) { /* translators: %s: what the site calls its conversions. */ $add( $s, sprintf( __( '%s from AI', 'turtlereport' ), $site->conversion_label() ), $data['ai']['conv'], $data['ai']['conv_prev'], Format::number( $data['ai']['conv'] ), Format::number( $data['ai']['conv_prev'] ), 'abs', false, true ); } } if ( isset( $data['business']['cur'] ) && ! isset( $data['business']['error'] ) ) { $c = $data['business']['cur']; $p = $data['business']['prev']; $s = __( 'Google Business Profile', 'turtlereport' ); foreach ( array( 'calls' => __( 'Calls', 'turtlereport' ), 'web' => __( 'Website clicks', 'turtlereport' ), 'dirs' => __( 'Direction requests', 'turtlereport' ), 'views' => __( 'Profile views', 'turtlereport' ), ) as $k => $label ) { $add( $s, $label, $c[ $k ], $p[ $k ], Format::number( $c[ $k ] ), Format::number( $p[ $k ] ), 'rel' ); } } return $rows; } /** * The overall change: the average score of the main rows. * * @param array[] $rows From rows(). * @return array{average:float|null,text:string,color:string,better:int,worse:int,same:int,count:int} */ public static function overall( array $rows ) { $scores = array(); $better = 0; $worse = 0; $same = 0; foreach ( $rows as $row ) { if ( $row['sub'] ) { continue; } if ( Format::GOOD === $row['change']['color'] ) { ++$better; } elseif ( Format::BAD === $row['change']['color'] ) { ++$worse; } else { ++$same; } if ( null !== $row['score'] ) { // One small number tripling (15 → 45 calls) shouldn't swing the whole picture. $scores[] = max( -100.0, min( 100.0, (float) $row['score'] ) ); } } $avg = $scores ? array_sum( $scores ) / count( $scores ) : null; if ( null === $avg ) { $text = '–'; $color = Format::FLAT; } elseif ( abs( $avg ) < 0.5 ) { $text = __( 'No change', 'turtlereport' ); $color = Format::FLAT; } else { $text = ( $avg > 0 ? '+' : '−' ) . number_format_i18n( abs( $avg ), abs( $avg ) < 10 ? 1 : 0 ) . '%'; $color = $avg > 0 ? Format::GOOD : Format::BAD; } return array( 'average' => $avg, 'text' => $text, 'color' => $color, 'better' => $better, 'worse' => $worse, 'same' => $same, 'count' => count( $scores ), ); } /** * Column names: "September 2026" and "August 2026" for whole months, * otherwise "This period" and the comparison period in words, e.g. * "the previous 7 days" (Insights::versus()), so "Change vs …" reads. * * @param array $period Period with s, e, ps and pe. * @return array{0:string,1:string} */ public static function columns( array $period ) { $month = static function ( $s, $e ) { $first = substr( $s, 8, 2 ) === '01'; $last = gmdate( 'Y-m-t', strtotime( $s . ' 12:00:00 UTC' ) ) === $e; return $first && $last ? wp_date( 'F Y', strtotime( $s . ' 12:00:00 UTC' ), new \DateTimeZone( 'UTC' ) ) : ''; }; $a = $month( $period['s'], $period['e'] ); $b = $month( $period['ps'], $period['pe'] ); if ( '' !== $a && '' !== $b ) { return array( $a, $b ); } return array( __( 'This period', 'turtlereport' ), Insights::versus( $period ) ); } }