PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / seo / email-report-sections / class-key-metrics-section.php

class-key-metrics-section.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/seo/email-report-sections/class-key-metrics-section.php

193 lines 8.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Key Metrics Section — the report's hero card.
4 *
5 * Clicks, impressions, CTR and average position for the period, each with
6 * its change against the previous period of the same length. The numbers
7 * come from the whole-property totals the Data Provider pulls for both
8 * windows (#742), so the change is exact rather than summed from capped
9 * row lists.
10 *
11 * @package ThinkRank
12 * @subpackage SEO\Email_Report_Sections
13 * @since 1.9.0
14 */
15
16 declare(strict_types=1);
17
18 namespace ThinkRank\SEO\Email_Report_Sections;
19
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 final class Key_Metrics_Section implements Email_Report_Section_Interface {
25
26 public function key(): string {
27 return 'key_metrics';
28 }
29
30 public function label(): string {
31 return __('Search performance', 'thinkrank');
32 }
33
34 public function default_enabled(): bool {
35 return true;
36 }
37
38 public function requires_capability(): ?string {
39 return null;
40 }
41
42 /**
43 * The card carries its own heading (site name, period) — not the label.
44 */
45 public function renders_own_heading(): bool {
46 return true;
47 }
48
49 public function collect(array $context): array {
50 $shared = $context['shared'] ?? [];
51 if (empty($shared['available']) || empty($shared['readiness']['search_console'])) {
52 return [];
53 }
54
55 $totals = $shared['comparison']['totals'] ?? [];
56 $current = is_array($totals['current'] ?? null) ? $totals['current'] : [];
57 $previous = is_array($totals['previous'] ?? null) ? $totals['previous'] : [];
58
59 // Fall back to the dashboard totals when the dimensionless query
60 // returned nothing — same numbers, just without a previous window.
61 if ((int) ($current['clicks'] ?? 0) === 0 && (int) ($current['impressions'] ?? 0) === 0) {
62 $dash = $shared['current']['search_performance']['totals'] ?? [];
63 $current = [
64 'clicks' => (int) ($dash['clicks'] ?? 0),
65 'impressions' => (int) ($dash['impressions'] ?? 0),
66 'position' => (float) ($dash['position'] ?? 0.0),
67 ];
68 }
69
70 $has_previous = (int) ($previous['clicks'] ?? 0) > 0 || (int) ($previous['impressions'] ?? 0) > 0;
71
72 return [
73 'clicks' => (int) ($current['clicks'] ?? 0),
74 'impressions' => (int) ($current['impressions'] ?? 0),
75 'position' => round((float) ($current['position'] ?? 0.0), 1),
76 'prev_clicks' => $has_previous ? (int) ($previous['clicks'] ?? 0) : null,
77 'prev_impressions' => $has_previous ? (int) ($previous['impressions'] ?? 0) : null,
78 'prev_position' => $has_previous ? round((float) ($previous['position'] ?? 0.0), 1) : null,
79 'period_days' => (int) ($context['frequency_days'] ?? 30),
80 'period_label' => (string) ($context['period_label'] ?? ''),
81 ];
82 }
83
84 /**
85 * Zero clicks and zero impressions is a connected property with no
86 * traffic — a placeholder, not a report.
87 */
88 public function has_data(array $payload): bool {
89 return (int) ($payload['clicks'] ?? 0) > 0 || (int) ($payload['impressions'] ?? 0) > 0;
90 }
91
92 public function render(array $payload): string {
93 $days = (int) ($payload['period_days'] ?? 30);
94 $ctr = self::ctr($payload['clicks'], $payload['impressions']);
95
96 $clicks_change = '';
97 $imps_change = '';
98 $ctr_change = '';
99 $pos_change = '';
100 $compared = '';
101 if ($payload['prev_clicks'] !== null) {
102 $c = Email_Report_Html::pct_change((float) $payload['clicks'], (float) $payload['prev_clicks']);
103 $i = Email_Report_Html::pct_change((float) $payload['impressions'], (float) $payload['prev_impressions']);
104 $clicks_change = $c ? Email_Report_Html::change($c['text'], $c['direction'], $c['direction'] !== 'down') : '';
105 $imps_change = $i ? Email_Report_Html::change($i['text'], $i['direction'], $i['direction'] !== 'down') : '';
106
107 $prev_ctr = self::ctr((int) $payload['prev_clicks'], (int) $payload['prev_impressions']);
108 $pts = round(($ctr - $prev_ctr) * 100, 1);
109 if (abs($pts) >= 0.1) {
110 $ctr_change = Email_Report_Html::change(
111 number_format_i18n(abs($pts), 1),
112 $pts > 0 ? 'up' : 'down',
113 $pts > 0,
114 __('pts', 'thinkrank')
115 );
116 } elseif ($prev_ctr > 0) {
117 $ctr_change = Email_Report_Html::change('0', 'flat', true);
118 }
119
120 if ((float) $payload['prev_position'] > 0 && (float) $payload['position'] > 0) {
121 $diff = round((float) $payload['prev_position'] - (float) $payload['position'], 1);
122 if (abs($diff) >= 0.1) {
123 // A smaller position number is better, so "up" here means improved.
124 $pos_change = Email_Report_Html::change(
125 number_format_i18n(abs($diff), 1),
126 $diff > 0 ? 'up' : 'down',
127 $diff > 0,
128 $diff > 0 ? __('better', 'thinkrank') : __('worse', 'thinkrank')
129 );
130 } else {
131 $pos_change = Email_Report_Html::change('0', 'flat', true);
132 }
133 }
134
135 $compared = ' · ' . sprintf(
136 /* translators: %d: number of days in the comparison window. */
137 _n('compared with the previous %d day', 'compared with the previous %d days', $days, 'thinkrank'),
138 $days
139 );
140 }
141
142 $eyebrow = '<div style="' . Email_Report_Html::FONT . 'font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:' . Email_Report_Html::PRIMARY . ';">'
143 . esc_html((string) get_bloginfo('name')) . '</div>';
144 $title = '<div style="' . Email_Report_Html::FONT . 'font-size:24px;font-weight:800;color:' . Email_Report_Html::INK . ';letter-spacing:-0.02em;line-height:1.25;margin-top:6px;">'
145 . esc_html(sprintf(
146 /* translators: %d: number of days the report covers. */
147 _n('Your search performance for the last %d day', 'Your search performance for the last %d days', $days, 'thinkrank'),
148 $days
149 )) . '</div>';
150 $sub = '<div style="' . Email_Report_Html::FONT . 'font-size:14px;color:' . Email_Report_Html::MUTED . ';margin-top:6px;">'
151 . esc_html((string) $payload['period_label'] . $compared) . '</div>';
152
153 $tiles = [
154 Email_Report_Html::stat([
155 'label' => __('Clicks from Google', 'thinkrank'),
156 'value' => number_format_i18n((int) $payload['clicks']),
157 'change' => $clicks_change,
158 'icon' => 'clicks',
159 ]),
160 Email_Report_Html::stat([
161 'label' => __('Impressions', 'thinkrank'),
162 'value' => number_format_i18n((int) $payload['impressions']),
163 'change' => $imps_change,
164 'icon' => 'impressions',
165 ]),
166 Email_Report_Html::stat([
167 'label' => __('Average CTR', 'thinkrank'),
168 'value' => number_format_i18n($ctr * 100, 1) . '%',
169 'change' => $ctr_change,
170 'icon' => 'ctr',
171 ]),
172 Email_Report_Html::stat([
173 'label' => __('Average position', 'thinkrank'),
174 'value' => (float) $payload['position'] > 0 ? number_format_i18n((float) $payload['position'], 1) : '—',
175 'change' => $pos_change,
176 'icon' => 'position',
177 ]),
178 ];
179
180 return $eyebrow . $title . $sub
181 . Email_Report_Html::stat_grid($tiles)
182 . Email_Report_Html::note(__('Source: Google Search Console. Data is complete up to 2 days before this report.', 'thinkrank'), '8px');
183 }
184
185 public function fallback_html(): string {
186 return '';
187 }
188
189 private static function ctr(int $clicks, int $impressions): float {
190 return $impressions > 0 ? $clicks / $impressions : 0.0;
191 }
192 }
193