PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.2 2.14.1 2.14.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 All 57 releases
← All changes | includes/seo/class-email-report-data-provider.php +502 -29 1.27.0 → 2.14.1 View file →
@@ -6,12 +6,17 @@
6 6 * preceding period of equal length, then hands both to sections so they
7 7 * can diff and rank. Sections never call Analytics_Manager directly —
8 8 * one fetch per period, shared across the report.
9 9 *
10 - * If Analytics_Manager isn't available (no Google connection, etc.) the
11 - * provider returns an `available => false` result; sections then render
12 - * their fallback HTML per PRD's graceful-degradation requirement.
10 + * readiness() answers, before any data is pulled, whether there is a report
11 + * to build: Search Console is required, Google Analytics 4 and the AI
12 + * traffic tracker each add a card when present (#742). The generator asks
13 + * this first so a disconnected site is paused with a reason rather than
14 + * fetched, rendered and sent as a column of blanks.
13 15 *
16 + * If Analytics_Manager isn't available the provider returns an
17 + * `available => false` result and sections collect nothing.
18 + *
14 19 * @package ThinkRank
15 20 * @subpackage SEO
16 21 * @since 1.9.0
17 22 */
@@ -33,8 +38,153 @@
33 38 */
34 39 final class Email_Report_Data_Provider {
35 40
36 41 /**
42 + * Rows fetched per window for the query and page comparisons.
43 + *
44 + * Search Console orders rows by clicks and stops here, so a list that
45 + * comes back this long may be truncated: a row missing from it may sit
46 + * just below the cut rather than have no traffic (#906).
47 + */
48 + private const COMPARISON_ROW_LIMIT = 1000;
49 +
50 + /**
51 + * How many apparent drop-outs are confirmed against the current window.
52 + *
53 + * The losing cards show five rows, so the biggest previous-period
54 + * candidates are all that can reach them. Anything past this cap is left
55 + * out of the comparison rather than presumed lost.
56 + */
57 + private const DROPOUT_CONFIRM_LIMIT = 25;
58 +
59 + /**
60 + * Longest regular expression sent in one confirmation request. Search
61 + * Console rejects longer expressions, so candidates are batched under it.
62 + */
63 + private const DROPOUT_REGEX_MAX_LENGTH = 3500;
64 +
65 + /**
66 + * Which data sources the report can draw on right now.
67 + *
68 + * Cheap on purpose — no dashboard fetch, no Search Console query. It
69 + * runs on every hourly tick while a site is paused and on every load of
70 + * the Email Reporting panel.
71 + *
72 + * @return array{
73 + * ready:bool,
74 + * search_console:bool,
75 + * analytics:bool,
76 + * ai_traffic:bool,
77 + * reason:?string
78 + * }
79 + */
80 + public function readiness(): array {
81 + // Credentials, not client objects. Analytics_Manager constructs a
82 + // Search Console client whether or not a token exists, so "is there
83 + // a client?" is always yes. The predicates below are the ones
84 + // get-integrations-status reports, so the panel, the ability and
85 + // the report can never disagree about the same site.
86 + $settings = $this->settings();
87 +
88 + $oauth_present = '' !== (string) $settings->get('google_access_token', '');
89 + $search_console = $oauth_present
90 + || '' !== (string) $settings->get('google_search_console_api_key', '');
91 +
92 + // GA4: the Data API client is built only when a token AND a selected
93 + // property both exist — mirror that exactly.
94 + $ga_property = (string) $settings->get('seo_analytics_google_analytics_property_id', '');
95 + $analytics = ($oauth_present && '' !== $ga_property)
96 + || '' !== (string) $settings->get('google_analytics_api_key', '');
97 +
98 + $readiness = [
99 + 'ready' => $search_console,
100 + 'search_console' => $search_console,
101 + 'analytics' => $analytics,
102 + 'ai_traffic' => $this->ai_tracker_has_data(),
103 + 'reason' => $search_console ? null : 'search_console_not_connected',
104 + ];
105 +
106 + /**
107 + * Filter the report's readiness.
108 + *
109 + * Lets a host that feeds the report from somewhere other than the
110 + * Google integrations declare itself ready, and lets tests force
111 + * either state.
112 + *
113 + * @since 2.8.0
114 + *
115 + * @param array $readiness See readiness().
116 + */
117 + $filtered = apply_filters('thinkrank_email_report_readiness', $readiness);
118 +
119 + return is_array($filtered) ? array_merge($readiness, $filtered) : $readiness;
120 + }
121 +
122 + /**
123 + * Subject-line tokens for a fetched report (#742).
124 + *
125 + * `%headline%` is the sentence the default subject is built from:
126 + * "12,480 Google clicks (+12.4%) in the last 30 days" when both windows
127 + * have totals, "Your SEO report for Aug 19 – Sep 17" when there is
128 + * nothing to compare. The parts are exposed as their own tokens so a
129 + * custom subject template can rebuild it differently.
130 + *
131 + * @param array $shared Output of fetch().
132 + * @param int $frequency_days Report window in days.
133 + * @return array<string,string> Token => value.
134 + */
135 + public static function subject_tokens(array $shared, int $frequency_days): array {
136 + $days = max(1, $frequency_days);
137 + $label = (string) ($shared['period_label'] ?? '');
138 + $totals = $shared['comparison']['totals'] ?? [];
139 + $current = is_array($totals['current'] ?? null) ? $totals['current'] : [];
140 + $previous = is_array($totals['previous'] ?? null) ? $totals['previous'] : [];
141 +
142 + $clicks = (int) ($current['clicks'] ?? 0);
143 + $impressions = (int) ($current['impressions'] ?? 0);
144 + if ($clicks === 0 && $impressions === 0) {
145 + $dash = $shared['current']['search_performance']['totals'] ?? [];
146 + $clicks = (int) ($dash['clicks'] ?? 0);
147 + $impressions = (int) ($dash['impressions'] ?? 0);
148 + }
149 +
150 + $change = null;
151 + if ((int) ($previous['clicks'] ?? 0) > 0 && class_exists(Email_Report_Sections\Email_Report_Html::class)) {
152 + $change = Email_Report_Sections\Email_Report_Html::pct_change((float) $clicks, (float) ($previous['clicks'] ?? 0));
153 + }
154 + $signed = $change !== null && $change['direction'] !== 'flat'
155 + ? ($change['direction'] === 'up' ? '+' : '−') . $change['text']
156 + : '';
157 +
158 + if ($clicks > 0 || $impressions > 0) {
159 + $headline = sprintf(
160 + /* translators: 1: number of clicks, 2: change in parentheses or empty, 3: number of days. */
161 + _n('%1$s Google clicks%2$s in the last %3$d day', '%1$s Google clicks%2$s in the last %3$d days', $days, 'thinkrank'),
162 + number_format_i18n($clicks),
163 + $signed !== '' ? ' (' . $signed . ')' : '',
164 + $days
165 + );
166 + } else {
167 + $headline = $label !== ''
168 + ? sprintf(
169 + /* translators: %s: period label, e.g. "Aug 19 – Sep 17, 2026". */
170 + __('Your SEO report for %s', 'thinkrank'),
171 + $label
172 + )
173 + : __('Your SEO report', 'thinkrank');
174 + }
175 +
176 + return [
177 + '%period%' => $label,
178 + '%period_days%' => (string) $days,
179 + '%clicks%' => number_format_i18n($clicks),
180 + '%clicks_change%' => $signed,
181 + '%impressions%' => number_format_i18n($impressions),
182 + '%headline%' => $headline,
183 + ];
184 + }
185 +
186 + /**
37 187 * Pull current + prior period dashboard data.
38 188 *
39 189 * @param int $frequency_days Reporting frequency in days.
40 190 * @return array{
@@ -48,11 +198,20 @@
48 198 * }
49 199 */
50 200 public function fetch(int $frequency_days): array {
51 201 $frequency_days = max(1, $frequency_days);
52 - $today = current_time('Y-m-d');
53 - $period_end = $today;
54 - $period_start = wp_date('Y-m-d', strtotime("-{$frequency_days} days", strtotime($today)));
202 +
203 + // One canonical window for the whole report. Search Console lags
204 + // ~2 days, so it ends on the last date that actually has data —
205 + // the same anchor Analytics_Manager::get_dashboard_data() uses for
206 + // Key Metrics and Position Summary. Previously the header label ran
207 + // through today and the comparison through yesterday, so a reader
208 + // was handed three windows and told they were one.
209 + $period_end = gmdate('Y-m-d', strtotime('-2 days'));
210 + $period_start = gmdate(
211 + 'Y-m-d',
212 + strtotime('-' . ($frequency_days - 1) . ' days', strtotime($period_end))
213 + );
55 214 $period_label = $this->format_period_label($period_start, $period_end);
56 215
57 216 $manager = $this->get_analytics_manager();
58 217 if ($manager === null) {
@@ -74,14 +233,16 @@
74 233 // Real period-over-period comparison: pull query- and page-level
75 234 // metrics for the current window AND the immediately preceding
76 235 // window of equal length straight from Search Console, then key
77 236 // them so winning/losing sections can compute true deltas.
78 - $comparison = $this->build_comparison($manager, $frequency_days);
237 + $comparison = $this->build_comparison($manager, $frequency_days, $period_start, $period_end);
79 238
80 239 return [
81 240 'available' => true,
241 + 'readiness' => $this->readiness(),
82 242 'current' => is_array($current) ? $current : [],
83 243 'comparison' => $comparison,
244 + 'ai' => $this->ai_summary($frequency_days),
84 245 'period_start' => $period_start,
85 246 'period_end' => $period_end,
86 247 'period_label' => $period_label,
87 248 ];
@@ -103,18 +264,23 @@
103 264 *
104 265 * Uses the Search Console client's arbitrary date-range API
105 266 * (`get_search_performance_by_dates`) — the same one the Rank Tracker
106 267 * and the Pro winning/losing endpoint use — to fetch query- and
107 - * page-level rows for two equal, adjacent windows. GSC lags ~2 days, so
108 - * the current window ends yesterday and the previous window is the N
109 - * days before it.
268 + * page-level rows for two equal, adjacent windows.
110 269 *
270 + * The current window is handed in by fetch() rather than recomputed
271 + * here, so the deltas describe exactly the period the report's header
272 + * advertises. The previous window is the same length, immediately
273 + * before it, with no gap or overlap.
274 + *
111 275 * @param object $manager Analytics_Manager instance.
112 276 * @param int $frequency_days Window length in days.
277 + * @param string $cur_start Current window start (Y-m-d).
278 + * @param string $cur_end Current window end (Y-m-d).
113 279 * @return array{available:bool,queries:array,pages:array}
114 280 */
115 - private function build_comparison($manager, int $frequency_days): array {
116 - $empty = ['available' => false, 'queries' => [], 'pages' => []];
281 + private function build_comparison($manager, int $frequency_days, string $cur_start, string $cur_end): array {
282 + $empty = ['available' => false, 'queries' => [], 'pages' => [], 'totals' => []];
117 283
118 284 if (!method_exists($manager, 'get_search_console_client')) {
119 285 return $empty;
120 286 }
@@ -126,35 +292,281 @@
126 292 if ($site_url === '') {
127 293 return $empty;
128 294 }
129 295
130 - $cur_end = gmdate('Y-m-d', strtotime('-1 day'));
131 - $cur_start = gmdate('Y-m-d', strtotime('-' . $frequency_days . ' days'));
132 - $prev_end = gmdate('Y-m-d', strtotime('-' . ($frequency_days + 1) . ' days'));
133 - $prev_start = gmdate('Y-m-d', strtotime('-' . ($frequency_days * 2) . ' days'));
296 + // Previous window: the same number of days, ending the day before
297 + // the current window opens.
298 + $prev_end = gmdate('Y-m-d', strtotime('-1 day', strtotime($cur_start)));
299 + $prev_start = gmdate('Y-m-d', strtotime('-' . ($frequency_days - 1) . ' days', strtotime($prev_end)));
134 300
135 - $cur_q = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['query']);
136 - $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['query']);
137 - $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['page']);
138 - $prev_p = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['page']);
301 + $limit = self::COMPARISON_ROW_LIMIT;
302 + $cur_q = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, $limit, ['query']);
303 + $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, $limit, ['query']);
304 + $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, $limit, ['page']);
305 + $prev_p = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, $limit, ['page']);
139 306
307 + // A row missing from a full current list may only have fallen below
308 + // the cut. Ask Search Console about the likely losers directly before
309 + // any of them is reported as having lost everything (#906).
310 + [$found_q, $dropouts_q] = $this->confirm_dropouts($sc, $site_url, $cur_start, $cur_end, $cur_q, $prev_q, 'query');
311 + [$found_p, $dropouts_p] = $this->confirm_dropouts($sc, $site_url, $cur_start, $cur_end, $cur_p, $prev_p, 'page');
312 +
313 + // Whole-property totals for both windows. A query with no
314 + // dimensions returns one aggregated row, so the hero's clicks,
315 + // impressions, CTR and position — and their change — are exact
316 + // rather than summed from the 1,000-row query lists above.
317 + $cur_t = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1, []);
318 + $prev_t = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1, []);
319 +
140 320 return [
141 321 'available' => true,
142 - 'queries' => $this->merge_periods($cur_q, $prev_q, true),
143 - 'pages' => $this->merge_periods($cur_p, $prev_p, false),
322 + 'queries' => $this->merge_periods(array_merge($cur_q, $found_q), $prev_q, true, $dropouts_q),
323 + 'pages' => $this->merge_periods(array_merge($cur_p, $found_p), $prev_p, false, $dropouts_p),
324 + 'totals' => [
325 + 'current' => $this->totals_row($cur_t),
326 + 'previous' => $this->totals_row($prev_t),
327 + ],
144 328 ];
145 329 }
146 330
147 331 /**
332 + * Sort out which previous-window rows missing from the current list
333 + * really lost all their traffic.
334 + *
335 + * Both windows are capped lists ordered by clicks. Absence from a
336 + * current list shorter than the cap is real: Search Console returned
337 + * everything it has. Absence from a full list is not evidence of
338 + * anything, because growth elsewhere pushes unchanged rows below the
339 + * cut. Those were synthesised as total losses and led the losing cards,
340 + * labelled "No impressions this period", with the same clicks in both
341 + * windows (#906).
342 + *
343 + * So when the current list is full, the biggest candidates by previous
344 + * clicks are looked up in the current window by exact key. A candidate
345 + * that comes back has its real current row returned, so its change is
346 + * measured rather than presumed. Only one that comes back empty is a
347 + * drop-out. Candidates past DROPOUT_CONFIRM_LIMIT, or in a batch whose
348 + * request failed, are neither: they are left out of the comparison,
349 + * since saying nothing is better than reporting a loss nobody measured.
350 + *
351 + * @since 2.15.0
352 + *
353 + * @param object $sc Search Console client.
354 + * @param string $site_url Property URL.
355 + * @param string $cur_start Current window start (Y-m-d).
356 + * @param string $cur_end Current window end (Y-m-d).
357 + * @param array $current Current-window rows, as fetched.
358 + * @param array $previous Previous-window rows, as fetched.
359 + * @param string $dimension 'query' or 'page'.
360 + * @return array{0: array, 1: ?array<string,bool>} Current rows found
361 + * below the cut, and the confirmed drop-out keys, or null when
362 + * the current list is complete and every absence is real.
363 + */
364 + private function confirm_dropouts($sc, string $site_url, string $cur_start, string $cur_end, array $current, array $previous, string $dimension): array {
365 + if (count($current) < self::COMPARISON_ROW_LIMIT) {
366 + return [[], null];
367 + }
368 +
369 + $is_query = 'query' === $dimension;
370 +
371 + $present = [];
372 + foreach ($current as $row) {
373 + $raw = (string) ($row['keys'][0] ?? '');
374 + if ('' !== $raw) {
375 + $present[$this->normalize_key($raw, $is_query)] = true;
376 + }
377 + }
378 +
379 + $candidates = [];
380 + foreach ($previous as $row) {
381 + $raw = (string) ($row['keys'][0] ?? '');
382 + if ('' === $raw) {
383 + continue;
384 + }
385 + $key = $this->normalize_key($raw, $is_query);
386 + if (!isset($present[$key]) && !isset($candidates[$key])) {
387 + $candidates[$key] = ['raw' => $raw, 'clicks' => (int) ($row['clicks'] ?? 0)];
388 + }
389 + }
390 +
391 + if ([] === $candidates) {
392 + return [[], []];
393 + }
394 +
395 + uasort($candidates, static fn($a, $b) => $b['clicks'] <=> $a['clicks']);
396 + $candidates = array_slice($candidates, 0, self::DROPOUT_CONFIRM_LIMIT, true);
397 +
398 + // Batch the exact-match lookups into as few requests as the
399 + // expression length allows: one per dimension in practice.
400 + $batches = [];
401 + $batch = [];
402 + $length = 0;
403 + foreach ($candidates as $key => $candidate) {
404 + $part = $this->re2_quote($candidate['raw']);
405 + if ([] !== $batch && $length + strlen($part) + 1 > self::DROPOUT_REGEX_MAX_LENGTH) {
406 + $batches[] = $batch;
407 + $batch = [];
408 + $length = 0;
409 + }
410 + $batch[$key] = $part;
411 + $length += strlen($part) + 1;
412 + }
413 + $batches[] = $batch;
414 +
415 + $found = [];
416 + $dropouts = [];
417 + foreach ($batches as $batch) {
418 + try {
419 + $rows = $sc->get_search_performance_by_dates(
420 + $site_url,
421 + $cur_start,
422 + $cur_end,
423 + count($batch),
424 + [$dimension],
425 + [[
426 + 'dimension' => $dimension,
427 + 'operator' => 'includingRegex',
428 + 'expression' => '^(?:' . implode('|', $batch) . ')$',
429 + ]]
430 + );
431 + } catch (Throwable $e) {
432 + // Unconfirmed: leave these out rather than call them lost.
433 + continue;
434 + }
435 +
436 + $seen = [];
437 + foreach ((array) $rows as $row) {
438 + $raw = (string) ($row['keys'][0] ?? '');
439 + $key = '' === $raw ? '' : $this->normalize_key($raw, $is_query);
440 + if (isset($batch[$key])) {
441 + $seen[$key] = true;
442 + $found[] = $row;
443 + }
444 + }
445 +
446 + foreach (array_keys($batch) as $key) {
447 + if (!isset($seen[$key])) {
448 + $dropouts[$key] = true;
449 + }
450 + }
451 + }
452 +
453 + return [$found, $dropouts];
454 + }
455 +
456 + /**
457 + * Escape a literal for a Search Console (RE2) regular expression.
458 + *
459 + * RE2 accepts a backslash before any ASCII punctuation as that literal,
460 + * so every punctuation character is escaped, not only the ones that are
461 + * special today.
462 + *
463 + * @since 2.15.0
464 + *
465 + * @param string $literal Text to match exactly.
466 + * @return string
467 + */
468 + private function re2_quote(string $literal): string {
469 + return (string) preg_replace('/[[:punct:]]/', '\\\\$0', $literal);
470 + }
471 +
472 + /**
473 + * Normalise the single aggregate row Search Console returns for a
474 + * dimensionless query. An empty result (a property with no traffic in
475 + * the window) yields zeroes, which the hero treats as "no comparison".
476 + *
477 + * @param array $rows API rows.
478 + * @return array{clicks:int,impressions:int,ctr:float,position:float}
479 + */
480 + private function totals_row(array $rows): array {
481 + $row = is_array($rows[0] ?? null) ? $rows[0] : [];
482 + return [
483 + 'clicks' => (int) ($row['clicks'] ?? 0),
484 + 'impressions' => (int) ($row['impressions'] ?? 0),
485 + 'ctr' => (float) ($row['ctr'] ?? 0.0),
486 + 'position' => (float) ($row['position'] ?? 0.0),
487 + ];
488 + }
489 +
490 + /**
491 + * AI-assistant traffic for the current window and the one before it,
492 + * from the first-party tracker AI Insights already runs. Null when the
493 + * tracker is absent or has recorded nothing.
494 + *
495 + * The tracker summarises a trailing window, so the previous period is
496 + * the double window minus the current one.
497 + *
498 + * @return array{current:array,previous:array}|null
499 + */
500 + private function ai_summary(int $frequency_days): ?array {
501 + $tracker = $this->get_ai_tracker();
502 + if ($tracker === null) {
503 + return null;
504 + }
505 + try {
506 + $current = (array) $tracker->summary($frequency_days);
507 + if ((int) ($current['ai_sessions'] ?? 0) === 0 && (int) ($current['baseline'] ?? 0) === 0) {
508 + return null;
509 + }
510 + $double = (array) $tracker->summary($frequency_days * 2);
511 + $previous = [
512 + 'ai_sessions' => max(0, (int) ($double['ai_sessions'] ?? 0) - (int) ($current['ai_sessions'] ?? 0)),
513 + 'baseline' => max(0, (int) ($double['baseline'] ?? 0) - (int) ($current['baseline'] ?? 0)),
514 + ];
515 + return ['current' => $current, 'previous' => $previous];
516 + } catch (Throwable $e) {
517 + return null;
518 + }
519 + }
520 +
521 + private function ai_tracker_has_data(): bool {
522 + $tracker = $this->get_ai_tracker();
523 + if ($tracker === null) {
524 + return false;
525 + }
526 + try {
527 + $summary = (array) $tracker->summary(30);
528 + return (int) ($summary['ai_sessions'] ?? 0) > 0 || (int) ($summary['baseline'] ?? 0) > 0;
529 + } catch (Throwable $e) {
530 + return false;
531 + }
532 + }
533 +
534 + private function get_ai_tracker() {
535 + $cls = '\\ThinkRank\\SEO\\Ai_Traffic_Tracker';
536 + if (!class_exists($cls) || !method_exists($cls, 'summary')) {
537 + return null;
538 + }
539 + try {
540 + return new $cls();
541 + } catch (Throwable $e) {
542 + return null;
543 + }
544 + }
545 +
546 + /**
148 547 * Merge current + previous GSC rows into one keyed map carrying both
149 548 * periods' clicks and (for queries) average position.
150 549 *
151 - * @param array $current Current-window rows.
152 - * @param array $previous Previous-window rows.
153 - * @param bool $is_query True for query rows, false for page rows.
550 + * The key set is the union of both windows. Search Console omits rows
551 + * with no activity in a window, so a page or query that dropped to zero
552 + * clicks has no current row at all — keying off `$current` alone would
553 + * silently discard exactly the biggest losers the losing sections exist
554 + * to surface.
555 + *
556 + * Which absent rows count as drop-outs is up to the caller. Absence from
557 + * a truncated list proves nothing, so build_comparison() passes the keys
558 + * it confirmed with Search Console; any other absent row is left out
559 + * (#906). Null means the current list was complete and every absence is
560 + * a drop-out.
561 + *
562 + * @param array $current Current-window rows.
563 + * @param array $previous Previous-window rows.
564 + * @param bool $is_query True for query rows, false for page rows.
565 + * @param array<string,bool>|null $dropout_keys Confirmed drop-out keys, or null for all.
154 566 * @return array<string,array>
155 567 */
156 - private function merge_periods(array $current, array $previous, bool $is_query): array {
568 + private function merge_periods(array $current, array $previous, bool $is_query, ?array $dropout_keys = null): array {
157 569 $prev_map = [];
158 570 foreach ($previous as $row) {
159 571 $key = (string) ($row['keys'][0] ?? '');
160 572 if ($key === '') {
@@ -185,8 +597,37 @@
185 597 }
186 598 $merged[$key] = $entry;
187 599 }
188 600
601 + // Total drop-outs: present last period, absent now. Synthesize them
602 + // from the previous window with the current metrics zeroed. Position
603 + // stays null rather than 0 — "no data" is not "ranked first".
604 + foreach ($prev_map as $key => $prev_row) {
605 + if (isset($merged[$key])) {
606 + continue;
607 + }
608 + if (null !== $dropout_keys && !isset($dropout_keys[$key])) {
609 + continue;
610 + }
611 + $raw = (string) ($prev_row['keys'][0] ?? '');
612 + if ($raw === '') {
613 + continue;
614 + }
615 +
616 + $entry = [
617 + 'cur_clicks' => 0,
618 + 'prev_clicks' => (int) ($prev_row['clicks'] ?? 0),
619 + ];
620 + if ($is_query) {
621 + $entry['query'] = $raw;
622 + $entry['cur_pos'] = null;
623 + $entry['prev_pos'] = round((float) ($prev_row['position'] ?? 0), 1);
624 + } else {
625 + $entry['url'] = $raw;
626 + }
627 + $merged[$key] = $entry;
628 + }
629 +
189 630 return $merged;
190 631 }
191 632
192 633 private function normalize_key(string $key, bool $is_query): string {
@@ -192,8 +633,29 @@
192 633 private function normalize_key(string $key, bool $is_query): string {
193 634 return $is_query ? trim(strtolower($key)) : $key;
194 635 }
195 636
637 + /**
638 + * The plugin settings store, or a null-object when it is not loaded
639 + * (unit tests without the core classes), which reads as "nothing
640 + * configured".
641 + */
642 + private function settings() {
643 + $cls = '\\ThinkRank\\Core\\Settings';
644 + if (class_exists($cls) && method_exists($cls, 'instance')) {
645 + try {
646 + return $cls::instance();
647 + } catch (Throwable $e) {
648 + // Fall through to the null object.
649 + }
650 + }
651 + return new class() {
652 + public function get(string $key, $fallback = null) {
653 + return $fallback;
654 + }
655 + };
656 + }
657 +
196 658 private function get_analytics_manager() {
197 659 $cls = '\\ThinkRank\\SEO\\Analytics_Manager';
198 660 if (!class_exists($cls)) {
199 661 return null;
@@ -206,9 +668,20 @@
206 668 }
207 669
208 670 private function format_period_label(string $start, string $end): string {
209 671 $fmt = (string) get_option('date_format', 'M j, Y');
210 - $a = wp_date($fmt, strtotime($start) ?: time());
211 - $b = wp_date($fmt, strtotime($end) ?: time());
212 - return sprintf('%s – %s', $a, $b);
672 + return sprintf('%s – %s', $this->format_day($start, $fmt), $this->format_day($end, $fmt));
673 + }
674 +
675 + /**
676 + * Render a bare Y-m-d as a localized date.
677 + *
678 + * Anchored at midday UTC on purpose: wp_date() shifts the timestamp into
679 + * the site timezone, and a date parsed at midnight would render as the
680 + * day before on any negative offset. Midday leaves the calendar date
681 + * intact across every real-world offset.
682 + */
683 + private function format_day(string $date, string $format): string {
684 + $ts = strtotime($date . ' 12:00:00 UTC');
685 + return wp_date($format, $ts ?: time());
213 686 }
214 687 }