PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.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 All 54 releases
← All changes | includes/seo/email-report-sections/class-key-metrics-section.php +140 -38 2.0.2 → 2.13.0 View file →
@@ -1,10 +1,13 @@
1 1 <?php
2 2 /**
3 - * Key Metrics Section
3 + * Key Metrics Section — the report's hero card.
4 4 *
5 - * Renders traffic, impressions, average position, and total tracked keywords
6 - * for the period. PRD §3, Sections-to-Include item 1.
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.
7 10 *
8 11 * @package ThinkRank
9 12 * @subpackage SEO\Email_Report_Sections
10 13 * @since 1.9.0
@@ -24,9 +27,9 @@
24 27 return 'key_metrics';
25 28 }
26 29
27 30 public function label(): string {
28 - return __('Key Metrics', 'thinkrank');
31 + return __('Search performance', 'thinkrank');
29 32 }
30 33
31 34 public function default_enabled(): bool {
32 35 return true;
@@ -35,56 +38,155 @@
35 38 public function requires_capability(): ?string {
36 39 return null;
37 40 }
38 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 +
39 49 public function collect(array $context): array {
40 50 $shared = $context['shared'] ?? [];
41 - if (empty($shared['available'])) {
51 + if (empty($shared['available']) || empty($shared['readiness']['search_console'])) {
42 52 return [];
43 53 }
44 - $current = $shared['current'] ?? [];
45 - $traffic = $current['traffic'] ?? [];
46 - $search = $current['search_performance'] ?? [];
47 - $totals = $search['totals'] ?? [];
48 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 +
49 72 return [
50 - 'sessions' => (int) ($traffic['sessions'] ?? 0),
51 - 'users' => (int) ($traffic['users'] ?? 0),
52 - 'impressions' => (int) ($totals['impressions'] ?? 0),
53 - 'clicks' => (int) ($totals['clicks'] ?? 0),
54 - 'avg_position' => (float) ($totals['position'] ?? 0.0),
55 - 'total_keywords' => (int) (count($search['rows'] ?? [])),
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'] ?? ''),
56 81 ];
57 82 }
58 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 +
59 92 public function render(array $payload): string {
60 - $cells = [
61 - __('Sessions', 'thinkrank') => number_format_i18n((int) $payload['sessions']),
62 - __('Users', 'thinkrank') => number_format_i18n((int) $payload['users']),
63 - __('Impressions', 'thinkrank') => number_format_i18n((int) $payload['impressions']),
64 - __('Clicks', 'thinkrank') => number_format_i18n((int) $payload['clicks']),
65 - __('Avg. Position', 'thinkrank') => number_format_i18n((float) $payload['avg_position'], 1),
66 - __('Tracked Keywords', 'thinkrank') => number_format_i18n((int) $payload['total_keywords']),
67 - ];
93 + $days = (int) ($payload['period_days'] ?? 30);
94 + $ctr = self::ctr($payload['clicks'], $payload['impressions']);
68 95
69 - $html = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">';
70 - $i = 0;
71 - foreach ($cells as $label => $value) {
72 - if ($i % 3 === 0) {
73 - $html .= ($i === 0 ? '<tr>' : '</tr><tr>');
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);
74 118 }
75 - $html .= '<td style="width:33%;padding:10px;border:1px solid #e5e7eb;background:#f9fafb;text-align:center;">'
76 - . '<div style="font:500 12px/1.2 -apple-system,Segoe UI,Roboto,sans-serif;color:#6b7280;text-transform:uppercase;letter-spacing:.04em;">' . esc_html($label) . '</div>'
77 - . '<div style="font:700 20px/1.2 -apple-system,Segoe UI,Roboto,sans-serif;color:#111827;margin-top:4px;">' . esc_html($value) . '</div>'
78 - . '</td>';
79 - $i++;
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 + );
80 140 }
81 - $html .= '</tr></table>';
82 - return $html;
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');
83 183 }
84 184
85 185 public function fallback_html(): string {
86 - return '<p style="color:#6b7280;font-style:italic;">'
87 - . esc_html__('Data temporarily unavailable. Please check your connected integrations.', 'thinkrank')
88 - . '</p>';
186 + return '';
187 + }
188 +
189 + private static function ctr(int $clicks, int $impressions): float {
190 + return $impressions > 0 ? $clicks / $impressions : 0.0;
89 191 }
90 192 }