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.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 1.0.2 All 50 releases
← All changes | includes/api/class-usage-analytics-endpoint.php +222 -67 2.1.02.9.0 View file →
@@ -94,8 +94,12 @@
94 94 * Model IDs sourced from https://docs.anthropic.com/en/docs/about-claude/models
95 95 */
96 96 private const CLAUDE_PRICING = [
97 97 // Current models (recommended)
98 + 'claude-opus-5' => [
99 + 'input' => 5.00,
100 + 'output' => 25.00
101 + ],
98 102 'claude-opus-4-8' => [
99 103 'input' => 5.00,
100 104 'output' => 25.00
101 105 ],
@@ -144,8 +148,13 @@
144 148 'gemini-3.1-pro' => [
145 149 'input' => 2.00,
146 150 'output' => 12.00
147 151 ],
152 + // The id the UI offers; 3.1 Pro ships only under -preview.
153 + 'gemini-3.1-pro-preview' => [
154 + 'input' => 2.00,
155 + 'output' => 12.00
156 + ],
148 157 'gemini-3.5-flash' => [
149 158 'input' => 1.50,
150 159 'output' => 9.00
151 160 ],
@@ -192,8 +201,17 @@
192 201 'openai/gpt-4o-mini' => [
193 202 'input' => 0.15,
194 203 'output' => 0.60
195 204 ],
205 + 'anthropic/claude-sonnet-5' => [
206 + 'input' => 3.00,
207 + 'output' => 15.00
208 + ],
209 + 'google/gemini-3.5-flash' => [
210 + 'input' => 1.50,
211 + 'output' => 9.00
212 + ],
213 + // Retired upstream, kept so historical usage rows still price correctly.
196 214 'anthropic/claude-3.5-sonnet' => [
197 215 'input' => 3.00,
198 216 'output' => 15.00
199 217 ],
@@ -272,18 +290,28 @@
272 290 'type' => 'string',
273 291 'enum' => ['7d', '30d', '90d', 'all'],
274 292 'sanitize_callback' => 'sanitize_key'
275 293 ],
276 - 'group_by' => [
277 - 'default' => 'day',
278 - 'type' => 'string',
279 - 'enum' => ['day', 'week', 'month'],
280 - 'sanitize_callback' => 'sanitize_key'
281 - ],
282 294 'user_id' => [
283 295 'default' => 0,
284 296 'type' => 'integer',
285 297 'sanitize_callback' => 'absint'
298 + ],
299 + // Declared because the handler reads them. They were validated
300 + // only by the handler's own clamping, so they had no type
301 + // coercion and did not appear in the endpoint's schema.
302 + 'page' => [
303 + 'default' => 1,
304 + 'type' => 'integer',
305 + 'minimum' => 1,
306 + 'sanitize_callback' => 'absint'
307 + ],
308 + 'per_page' => [
309 + 'default' => 20,
310 + 'type' => 'integer',
311 + 'minimum' => 10,
312 + 'maximum' => 100,
313 + 'sanitize_callback' => 'absint'
286 314 ]
287 315 ]
288 316 ]);
289 317
@@ -301,9 +329,9 @@
301 329 ],
302 330 'provider' => [
303 331 'default' => 'all',
304 332 'type' => 'string',
305 - 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter'],
333 + 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'],
306 334 'sanitize_callback' => 'sanitize_key'
307 335 ],
308 336 'user_id' => [
309 337 'default' => 0,
@@ -362,9 +390,8 @@
362 390 'ai_actions' => $ai_metrics['total_actions'],
363 391 'features_used_count' => $ai_metrics['features_used_count'],
364 392 'most_used_feature' => $ai_metrics['most_used_feature'],
365 393 'most_used_count' => $ai_metrics['most_used_count'],
366 - 'success_rate' => $ai_metrics['success_rate'],
367 394 'content_briefs' => $brief_metrics['total_briefs'],
368 395 'feature_breakdown' => $ai_metrics['feature_breakdown'],
369 396 'provider_breakdown' => $cost_data['by_provider']
370 397 ],
@@ -428,8 +455,34 @@
428 455 return true;
429 456 }
430 457
431 458 /**
459 + * Bind the cache-invalidation listeners for the whole request lifecycle.
460 + *
461 + * The listeners used to be registered only by the constructor, which runs
462 + * on rest_api_init — so usage logged during cron, WP-CLI or an admin-post
463 + * request found no listener and the cached overview rode out its full TTL.
464 + * Called from API\Manager::init() on every request instead.
465 + *
466 + * @since 2.2.1
467 + * @return void
468 + */
469 + public static function boot_cache_invalidation(): void {
470 + static $booted = false;
471 +
472 + if ($booted) {
473 + return;
474 + }
475 +
476 + $booted = true;
477 +
478 + // Constructing the endpoint registers the listeners; the guard in
479 + // setup_cache_invalidation() keeps a later REST construction from
480 + // double-binding them.
481 + new self();
482 + }
483 +
484 + /**
432 485 * Set up cache invalidation hooks
433 486 *
434 487 * @since 1.0.0
435 488 * @return void
@@ -434,8 +487,23 @@
434 487 * @since 1.0.0
435 488 * @return void
436 489 */
437 490 private function setup_cache_invalidation(): void {
491 + // The endpoint is constructed more than once per request — once on
492 + // init via boot_cache_invalidation(), again on rest_api_init, and
493 + // potentially by callers resolving it on demand. Bind once per
494 + // request, or every event invalidates N times.
495 + //
496 + // A has_action() check cannot do this: the callback is [$this, ...]
497 + // and each construction is a different instance, so it never matches.
498 + static $bound = false;
499 +
500 + if ($bound) {
501 + return;
502 + }
503 +
504 + $bound = true;
505 +
438 506 // Invalidate analytics cache when AI usage is logged
439 507 add_action('thinkrank_ai_usage_logged', [$this, 'invalidate_analytics_cache']);
440 508
441 509 // Invalidate analytics cache when SEO scores are updated
@@ -521,8 +589,12 @@
521 589 'openai' => 0,
522 590 'claude' => 0,
523 591 'gemini' => 0,
524 592 'openrouter' => 0,
593 + // Costed only when the user told us what their endpoint charges;
594 + // otherwise it stays 0 and the UI shows "—" rather than implying
595 + // that a local model was free of charge or that we know the price.
596 + 'openai_compatible' => 0,
525 597 'total' => 0,
526 598 'by_provider' => []
527 599 ];
528 600
@@ -527,46 +599,50 @@
527 599 ];
528 600
529 601 foreach ($usage_data as $usage) {
530 602 $tokens = (int) $usage['tokens_used'];
531 - $provider = $usage['provider'];
603 + $provider = (string) $usage['provider'];
532 604
533 - // Estimate 70% input, 30% output tokens
534 - $input_tokens = $tokens * 0.7;
535 - $output_tokens = $tokens * 0.3;
605 + // Unknown provider: no pricing table, so it cannot be costed. Skip
606 + // rather than let `+=` invent a key that the total below misses.
607 + if (!isset($costs[$provider])) {
608 + continue;
609 + }
536 610
537 - $cost = 0;
611 + // Price at the model the request actually used. Reading only the
612 + // provider meant every row was costed at that provider's default
613 + // model, so this total disagreed with the per-record figures in
614 + // the Usage Breakdown tab — by 4.5x on a gpt-4o-mini workload.
615 + $metadata = !empty($usage['metadata']) ? json_decode((string) $usage['metadata'], true) : [];
616 + $model = is_array($metadata) && !empty($metadata['actual_model'])
617 + ? (string) $metadata['actual_model']
618 + : $this->get_default_model($provider);
538 619
539 - // Use the robust pricing helper for consistent cost calculation
540 - $pricing = $this->get_model_pricing($provider);
541 - if ($pricing) {
542 - $cost = ($input_tokens * $pricing['input'] / 1000000) +
543 - ($output_tokens * $pricing['output'] / 1000000);
544 - $costs[$provider] += $cost;
545 - }
620 + // Single source of truth for per-row pricing, shared with
621 + // get_detailed_usage_breakdown() so both tabs always agree.
622 + $costs[$provider] += $this->calculate_record_cost($provider, $tokens, $model);
546 623 }
547 624
548 - $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'];
625 + $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'] + $costs['openai_compatible'];
549 626
550 - // Format provider breakdown
551 - $costs['by_provider'] = [
552 - 'openai' => [
553 - 'cost' => round($costs['openai'], 4),
554 - 'percentage' => $costs['total'] > 0 ? round(($costs['openai'] / $costs['total']) * 100, 1) : 0
555 - ],
556 - 'claude' => [
557 - 'cost' => round($costs['claude'], 4),
558 - 'percentage' => $costs['total'] > 0 ? round(($costs['claude'] / $costs['total']) * 100, 1) : 0
559 - ],
560 - 'gemini' => [
561 - 'cost' => round($costs['gemini'], 4),
562 - 'percentage' => $costs['total'] > 0 ? round(($costs['gemini'] / $costs['total']) * 100, 1) : 0
563 - ],
564 - 'openrouter' => [
565 - 'cost' => round($costs['openrouter'], 4),
566 - 'percentage' => $costs['total'] > 0 ? round(($costs['openrouter'] / $costs['total']) * 100, 1) : 0
567 - ]
568 - ];
627 + // Report only providers that actually incurred cost. Emitting all four
628 + // unconditionally meant a site with no AI usage rendered four ranked
629 + // rows at "$0.0000 (0%)" — reading as "four providers were used and
630 + // each was free" — and made the panel's own "No provider cost data"
631 + // empty state unreachable.
632 + $costs['by_provider'] = [];
633 + foreach (['openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'] as $provider) {
634 + if ($costs[$provider] <= 0) {
635 + continue;
636 + }
637 +
638 + $costs['by_provider'][$provider] = [
639 + 'cost' => round($costs[$provider], 4),
640 + 'percentage' => $costs['total'] > 0
641 + ? round(($costs[$provider] / $costs['total']) * 100, 1)
642 + : 0
643 + ];
644 + }
569 645
570 646 return $costs;
571 647 }
572 648
@@ -606,9 +682,9 @@
606 682 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
607 683 $usage_data = $wpdb->get_results(
608 684 $wpdb->prepare(
609 685 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
610 - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
686 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
611 687 $user_id,
612 688 $cutoff
613 689 ),
614 690 ARRAY_A
@@ -617,9 +693,9 @@
617 693 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
618 694 $usage_data = $wpdb->get_results(
619 695 $wpdb->prepare(
620 696 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
621 - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d",
697 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d",
622 698 $user_id
623 699 ),
624 700 ARRAY_A
625 701 );
@@ -630,8 +706,16 @@
630 706 // get_overview_metrics() reads every key unconditionally, so a
631 707 // short array here surfaces as undefined-key warnings and null
632 708 // fields for any user with no AI usage yet (i.e. a fresh install).
633 709 // The values mirror what the loop below produces for zero rows.
710 + //
711 + // The change fields are COMPUTED here rather than hardcoded to 0.
712 + // An empty current window does not mean "nothing changed": a user
713 + // whose usage fell from five actions last month to none this month
714 + // was shown a 0 — rendered as the same em-dash a genuinely flat
715 + // period gets — instead of the -100% that actually happened.
716 + $previous = $this->get_previous_period_data($user_id, $date_condition);
717 +
634 718 return [
635 719 'total_actions' => 0,
636 720 'total_tokens' => 0,
637 721 'feature_breakdown' => [],
@@ -637,12 +721,17 @@
637 721 'feature_breakdown' => [],
638 722 'features_used_count' => 0,
639 723 'most_used_feature' => '',
640 724 'most_used_count' => 0,
641 - 'success_rate' => 0,
642 725 'usage_data' => [],
643 - 'cost_change' => 0,
644 - 'time_saved_change' => 0
726 + 'cost_change' => $this->calculate_percentage_change(
727 + array_key_exists('total_cost', $previous) ? $previous['total_cost'] : 0,
728 + 0.0
729 + ),
730 + 'time_saved_change' => $this->calculate_percentage_change(
731 + array_key_exists('time_saved', $previous) ? $previous['time_saved'] : 0,
732 + 0.0
733 + )
645 734 ];
646 735 }
647 736
648 737 // Calculate feature breakdown and new metrics
@@ -674,20 +763,24 @@
674 763 $most_used_count = $count;
675 764 }
676 765 }
677 766
678 - // Calculate success rate (assuming all logged actions are successful for now)
679 - // In future, we could track failed attempts separately
680 - $success_rate = $total_actions > 0 ? 100 : 0;
767 + // No success rate here on purpose. It used to be
768 + // `$total_actions > 0 ? 100 : 0` — a constant presented as a
769 + // measurement, and one that could only ever read 100% or 0%. Failed
770 + // AI calls are never written to this table, so there is nothing to
771 + // compute a rate from; the KPI card is gone until there is.
681 772
682 773 // Calculate changes from previous period
683 774 $previous_period_data = $this->get_previous_period_data($user_id, $date_condition);
775 + // Note the lack of `?? 0`: a null here means "no previous period",
776 + // and coalescing it to zero would turn that back into a fake 100%.
684 777 $cost_change = $this->calculate_percentage_change(
685 - $previous_period_data['total_cost'] ?? 0,
778 + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0,
686 779 $this->calculate_total_cost($usage_data)
687 780 );
688 781 $time_saved_change = $this->calculate_percentage_change(
689 - $previous_period_data['time_saved'] ?? 0,
782 + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0,
690 783 $this->calculate_time_saved($feature_breakdown)
691 784 );
692 785
693 786 return [
@@ -696,9 +789,8 @@
696 789 'feature_breakdown' => $feature_breakdown,
697 790 'features_used_count' => $features_used_count,
698 791 'most_used_feature' => $most_used_feature,
699 792 'most_used_count' => $most_used_count,
700 - 'success_rate' => $success_rate,
701 793 'usage_data' => $usage_data,
702 794 'cost_change' => $cost_change,
703 795 'time_saved_change' => $time_saved_change
704 796 ];
@@ -740,24 +832,38 @@
740 832 );
741 833 }
742 834
743 835 if (!$result || (int) $result['content_optimized'] === 0) {
836 + // Same reasoning as the empty branch in get_ai_usage_metrics():
837 + // an empty current window is not "no change". A user who
838 + // optimized three posts last month and none this month should
839 + // see -100%, not the em-dash a flat period gets — and for `all`
840 + // there is no previous window, so the change is null.
841 + $previous = $this->get_previous_seo_data($user_id, $date_condition);
842 +
744 843 return [
745 844 'content_optimized' => 0,
746 845 'average_seo_score' => 0,
747 - 'content_optimized_change' => 0,
748 - 'seo_score_change' => 0
846 + 'content_optimized_change' => $this->calculate_percentage_change(
847 + array_key_exists('content_optimized', $previous) ? $previous['content_optimized'] : 0,
848 + 0.0
849 + ),
850 + 'seo_score_change' => $this->calculate_percentage_change(
851 + array_key_exists('average_seo_score', $previous) ? $previous['average_seo_score'] : 0,
852 + 0.0
853 + )
749 854 ];
750 855 }
751 856
752 857 // Calculate changes from previous period
753 858 $previous_seo_data = $this->get_previous_seo_data($user_id, $date_condition);
859 + // As above: no `?? 0`, so a null "no previous period" survives.
754 860 $content_optimized_change = $this->calculate_percentage_change(
755 - $previous_seo_data['content_optimized'] ?? 0,
861 + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0,
756 862 (int) $result['content_optimized']
757 863 );
758 864 $seo_score_change = $this->calculate_percentage_change(
759 - $previous_seo_data['average_seo_score'] ?? 0,
865 + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0,
760 866 round((float) $result['average_score'], 1)
761 867 );
762 868
763 869 return [
@@ -814,9 +920,14 @@
814 920 * @return WP_REST_Response|WP_Error Response object
815 921 */
816 922 public function get_usage_breakdown(WP_REST_Request $request) {
817 923 try {
818 - $user_id = get_current_user_id();
924 + // Mirrors get_overview_metrics(). The two endpoints declared the
925 + // same `user_id` argument but only overview honoured it, so the
926 + // same query string described two different users depending on
927 + // which one you asked. check_permissions() already requires
928 + // manage_options before another user's id is accepted.
929 + $user_id = $request->get_param('user_id') ?: get_current_user_id();
819 930 $period = $request->get_param('period') ?? '30d';
820 931 // `(int)` binds tighter than `??`, so `(int) null` is 0 and the
821 932 // `?? 20` fallback was unreachable — per_page silently defaulted to
822 933 // the max(10, 0) floor of 10 rather than the 20 it advertises, and
@@ -839,9 +950,10 @@
839 950 'pagination' => [
840 951 'page' => $page,
841 952 'per_page' => $per_page,
842 953 'total_records' => $total_records,
843 - 'total_pages' => ceil($total_records / $per_page)
954 + // (int) so it serialises as 2, not 2.0.
955 + 'total_pages' => $per_page > 0 ? (int) ceil($total_records / $per_page) : 0
844 956 ],
845 957 'period' => $period
846 958 ]
847 959 ], 200);
@@ -889,14 +1001,22 @@
889 1001 * @param float $old_value Previous period value
890 1002 * @param float $new_value Current period value
891 1003 * @return float Percentage change
892 1004 */
893 - private function calculate_percentage_change(float $old_value, float $new_value): float {
1005 + private function calculate_percentage_change($old_value, float $new_value): ?float {
1006 + // No previous period at all (the 'all' range).
1007 + if (null === $old_value) {
1008 + return null;
1009 + }
1010 +
894 1011 if ((float) $old_value === 0.0) {
895 - return $new_value > 0 ? 100 : 0;
1012 + // Growth from nothing has no percentage. Reporting a flat 100%
1013 + // dressed it up as a measured change; null lets the UI say "new"
1014 + // (or say nothing) instead of inventing a number.
1015 + return $new_value > 0 ? null : 0.0;
896 1016 }
897 1017
898 - return round((($new_value - $old_value) / $old_value) * 100, 1);
1018 + return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1);
899 1019 }
900 1020
901 1021 /**
902 1022 * Get previous period data for comparison
@@ -913,8 +1033,14 @@
913 1033
914 1034 // Extract the interval from current date condition to calculate previous period
915 1035 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
916 1036
1037 + // No preceding window: report "not comparable" rather than querying a
1038 + // made-up one.
1039 + if (null === $previous_date_condition) {
1040 + return ['total_cost' => null, 'time_saved' => null];
1041 + }
1042 +
917 1043 // Prepare and execute query with proper parameter binding to prevent SQL injection
918 1044 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
919 1045 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
920 1046 $usage_data = $wpdb->get_results(
@@ -921,9 +1047,10 @@
921 1047 $wpdb->prepare("
922 1048 SELECT
923 1049 provider,
924 1050 action,
925 - tokens_used
1051 + tokens_used,
1052 + metadata
926 1053 FROM `{$table_name}`
927 1054 WHERE user_id = %d
928 1055 {$previous_date_condition}
929 1056 ", $user_id),
@@ -1091,9 +1218,9 @@
1091 1218 // Try specific model first, fallback to default
1092 1219 if ($model && isset(self::GEMINI_PRICING[$model])) {
1093 1220 return self::GEMINI_PRICING[$model];
1094 1221 }
1095 - return self::GEMINI_PRICING['gemini-2.5-flash'] ?? null;
1222 + return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null;
1096 1223
1097 1224 case 'openrouter':
1098 1225 // Try specific model first, fallback to default
1099 1226 if ($model && isset(self::OPENROUTER_PRICING[$model])) {
@@ -1100,8 +1227,22 @@
1100 1227 return self::OPENROUTER_PRICING[$model];
1101 1228 }
1102 1229 return self::OPENROUTER_PRICING['openai/gpt-4o-mini'] ?? null;
1103 1230
1231 + case 'openai_compatible':
1232 + // There is no price table for someone else's server: it may be
1233 + // a free local model, an Azure contract or a hosted open model.
1234 + // The only honest number is the one the administrator entered,
1235 + // as a flat per-1M-token rate applied to both directions.
1236 + //
1237 + // Read at report time, so changing the rate (or repointing the
1238 + // provider at another server) re-costs past rows too. Accepted:
1239 + // storing a price per row would mean a schema change for an
1240 + // estimate the administrator typed in the first place.
1241 + $price = (float) \ThinkRank\Core\Settings::instance()->get('openai_compatible_price_per_million', 0);
1242 +
1243 + return $price > 0 ? ['input' => $price, 'output' => $price] : null;
1244 +
1104 1245 default:
1105 1246 return null;
1106 1247 }
1107 1248 }
@@ -1121,8 +1262,11 @@
1121 1262 case 'gemini':
1122 1263 return \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL;
1123 1264 case 'openrouter':
1124 1265 return \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL;
1266 + case 'openai_compatible':
1267 + // Whatever the user pointed us at; there is no default.
1268 + return (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_model', '');
1125 1269 default:
1126 1270 return 'unknown';
1127 1271 }
1128 1272 }
@@ -1141,8 +1285,13 @@
1141 1285 $table_name = esc_sql($this->database->get_table('seo_scores'));
1142 1286
1143 1287 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1144 1288
1289 + // See get_previous_period_data(): no preceding window, no comparison.
1290 + if (null === $previous_date_condition) {
1291 + return ['content_optimized' => null, 'average_seo_score' => null];
1292 + }
1293 +
1145 1294 // Prepare and execute query with proper parameter binding to prevent SQL injection
1146 1295 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1147 1296 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
1148 1297 $result = $wpdb->get_row(
@@ -1189,12 +1338,18 @@
1189 1338
1190 1339 /**
1191 1340 * Convert current period condition to previous period condition
1192 1341 *
1342 + * Returns null when there is no preceding window to compare against.
1343 + * `all` produces an empty date condition, which used to fall through to a
1344 + * hardcoded 30–60 day fallback — so "all time" was compared against an
1345 + * arbitrary month and reported a large, meaningless increase. "No
1346 + * comparison" is now representable instead of being a parse failure.
1347 + *
1193 1348 * @param string $current_condition Current period SQL condition
1194 - * @return string Previous period SQL condition
1349 + * @return string|null Previous period SQL condition, or null when none exists
1195 1350 */
1196 - private function get_previous_period_condition(string $current_condition): string {
1351 + private function get_previous_period_condition(string $current_condition): ?string {
1197 1352 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1198 1353 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1199 1354 $interval = (int) $matches[1];
1200 1355 $unit = $matches[2];
@@ -1206,9 +1361,9 @@
1206 1361 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1207 1362 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1208 1363 }
1209 1364
1210 - // Fallback for unknown conditions
1211 - return "AND created_at >= DATE_SUB(NOW(), INTERVAL 60 DAY)
1212 - AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)";
1365 + // No interval means no window — 'all'. Comparing every record ever
1366 + // against a fabricated 30-day slice is not a trend.
1367 + return null;
1213 1368 }
1214 1369 }