← 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 | } |