PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-usage-analytics-endpoint.php +209 -66 2.0.02.7.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 ],
@@ -248,8 +266,9 @@
248 266 'permission_callback' => [$this, 'check_permissions'],
249 267 'args' => [
250 268 'period' => [
251 269 'default' => '30d',
270 + 'type' => 'string',
252 271 'enum' => ['7d', '30d', '90d', 'all'],
253 272 'sanitize_callback' => 'sanitize_key'
254 273 ],
255 274 'user_id' => [
@@ -267,20 +286,32 @@
267 286 'permission_callback' => [$this, 'check_permissions'],
268 287 'args' => [
269 288 'period' => [
270 289 'default' => '30d',
290 + 'type' => 'string',
271 291 'enum' => ['7d', '30d', '90d', 'all'],
272 292 'sanitize_callback' => 'sanitize_key'
273 293 ],
274 - 'group_by' => [
275 - 'default' => 'day',
276 - 'enum' => ['day', 'week', 'month'],
277 - 'sanitize_callback' => 'sanitize_key'
278 - ],
279 294 'user_id' => [
280 295 'default' => 0,
281 296 'type' => 'integer',
282 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'
283 314 ]
284 315 ]
285 316 ]);
286 317
@@ -291,13 +322,15 @@
291 322 'permission_callback' => [$this, 'check_permissions'],
292 323 'args' => [
293 324 'period' => [
294 325 'default' => '30d',
326 + 'type' => 'string',
295 327 'enum' => ['7d', '30d', '90d', 'all'],
296 328 'sanitize_callback' => 'sanitize_key'
297 329 ],
298 330 'provider' => [
299 331 'default' => 'all',
332 + 'type' => 'string',
300 333 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter'],
301 334 'sanitize_callback' => 'sanitize_key'
302 335 ],
303 336 'user_id' => [
@@ -357,9 +390,8 @@
357 390 'ai_actions' => $ai_metrics['total_actions'],
358 391 'features_used_count' => $ai_metrics['features_used_count'],
359 392 'most_used_feature' => $ai_metrics['most_used_feature'],
360 393 'most_used_count' => $ai_metrics['most_used_count'],
361 - 'success_rate' => $ai_metrics['success_rate'],
362 394 'content_briefs' => $brief_metrics['total_briefs'],
363 395 'feature_breakdown' => $ai_metrics['feature_breakdown'],
364 396 'provider_breakdown' => $cost_data['by_provider']
365 397 ],
@@ -423,8 +455,34 @@
423 455 return true;
424 456 }
425 457
426 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 + /**
427 485 * Set up cache invalidation hooks
428 486 *
429 487 * @since 1.0.0
430 488 * @return void
@@ -429,8 +487,23 @@
429 487 * @since 1.0.0
430 488 * @return void
431 489 */
432 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 +
433 506 // Invalidate analytics cache when AI usage is logged
434 507 add_action('thinkrank_ai_usage_logged', [$this, 'invalidate_analytics_cache']);
435 508
436 509 // Invalidate analytics cache when SEO scores are updated
@@ -522,46 +595,50 @@
522 595 ];
523 596
524 597 foreach ($usage_data as $usage) {
525 598 $tokens = (int) $usage['tokens_used'];
526 - $provider = $usage['provider'];
599 + $provider = (string) $usage['provider'];
527 600
528 - // Estimate 70% input, 30% output tokens
529 - $input_tokens = $tokens * 0.7;
530 - $output_tokens = $tokens * 0.3;
601 + // Unknown provider: no pricing table, so it cannot be costed. Skip
602 + // rather than let `+=` invent a key that the total below misses.
603 + if (!isset($costs[$provider])) {
604 + continue;
605 + }
531 606
532 - $cost = 0;
607 + // Price at the model the request actually used. Reading only the
608 + // provider meant every row was costed at that provider's default
609 + // model, so this total disagreed with the per-record figures in
610 + // the Usage Breakdown tab — by 4.5x on a gpt-4o-mini workload.
611 + $metadata = !empty($usage['metadata']) ? json_decode((string) $usage['metadata'], true) : [];
612 + $model = is_array($metadata) && !empty($metadata['actual_model'])
613 + ? (string) $metadata['actual_model']
614 + : $this->get_default_model($provider);
533 615
534 - // Use the robust pricing helper for consistent cost calculation
535 - $pricing = $this->get_model_pricing($provider);
536 - if ($pricing) {
537 - $cost = ($input_tokens * $pricing['input'] / 1000000) +
538 - ($output_tokens * $pricing['output'] / 1000000);
539 - $costs[$provider] += $cost;
540 - }
616 + // Single source of truth for per-row pricing, shared with
617 + // get_detailed_usage_breakdown() so both tabs always agree.
618 + $costs[$provider] += $this->calculate_record_cost($provider, $tokens, $model);
541 619 }
542 620
543 621 $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'];
544 622
545 - // Format provider breakdown
546 - $costs['by_provider'] = [
547 - 'openai' => [
548 - 'cost' => round($costs['openai'], 4),
549 - 'percentage' => $costs['total'] > 0 ? round(($costs['openai'] / $costs['total']) * 100, 1) : 0
550 - ],
551 - 'claude' => [
552 - 'cost' => round($costs['claude'], 4),
553 - 'percentage' => $costs['total'] > 0 ? round(($costs['claude'] / $costs['total']) * 100, 1) : 0
554 - ],
555 - 'gemini' => [
556 - 'cost' => round($costs['gemini'], 4),
557 - 'percentage' => $costs['total'] > 0 ? round(($costs['gemini'] / $costs['total']) * 100, 1) : 0
558 - ],
559 - 'openrouter' => [
560 - 'cost' => round($costs['openrouter'], 4),
561 - 'percentage' => $costs['total'] > 0 ? round(($costs['openrouter'] / $costs['total']) * 100, 1) : 0
562 - ]
563 - ];
623 + // Report only providers that actually incurred cost. Emitting all four
624 + // unconditionally meant a site with no AI usage rendered four ranked
625 + // rows at "$0.0000 (0%)" — reading as "four providers were used and
626 + // each was free" — and made the panel's own "No provider cost data"
627 + // empty state unreachable.
628 + $costs['by_provider'] = [];
629 + foreach (['openai', 'claude', 'gemini', 'openrouter'] as $provider) {
630 + if ($costs[$provider] <= 0) {
631 + continue;
632 + }
633 +
634 + $costs['by_provider'][$provider] = [
635 + 'cost' => round($costs[$provider], 4),
636 + 'percentage' => $costs['total'] > 0
637 + ? round(($costs[$provider] / $costs['total']) * 100, 1)
638 + : 0
639 + ];
640 + }
564 641
565 642 return $costs;
566 643 }
567 644
@@ -601,9 +678,9 @@
601 678 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
602 679 $usage_data = $wpdb->get_results(
603 680 $wpdb->prepare(
604 681 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
605 - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
682 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
606 683 $user_id,
607 684 $cutoff
608 685 ),
609 686 ARRAY_A
@@ -612,9 +689,9 @@
612 689 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
613 690 $usage_data = $wpdb->get_results(
614 691 $wpdb->prepare(
615 692 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
616 - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d",
693 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d",
617 694 $user_id
618 695 ),
619 696 ARRAY_A
620 697 );
@@ -625,8 +702,16 @@
625 702 // get_overview_metrics() reads every key unconditionally, so a
626 703 // short array here surfaces as undefined-key warnings and null
627 704 // fields for any user with no AI usage yet (i.e. a fresh install).
628 705 // The values mirror what the loop below produces for zero rows.
706 + //
707 + // The change fields are COMPUTED here rather than hardcoded to 0.
708 + // An empty current window does not mean "nothing changed": a user
709 + // whose usage fell from five actions last month to none this month
710 + // was shown a 0 — rendered as the same em-dash a genuinely flat
711 + // period gets — instead of the -100% that actually happened.
712 + $previous = $this->get_previous_period_data($user_id, $date_condition);
713 +
629 714 return [
630 715 'total_actions' => 0,
631 716 'total_tokens' => 0,
632 717 'feature_breakdown' => [],
@@ -632,12 +717,17 @@
632 717 'feature_breakdown' => [],
633 718 'features_used_count' => 0,
634 719 'most_used_feature' => '',
635 720 'most_used_count' => 0,
636 - 'success_rate' => 0,
637 721 'usage_data' => [],
638 - 'cost_change' => 0,
639 - 'time_saved_change' => 0
722 + 'cost_change' => $this->calculate_percentage_change(
723 + array_key_exists('total_cost', $previous) ? $previous['total_cost'] : 0,
724 + 0.0
725 + ),
726 + 'time_saved_change' => $this->calculate_percentage_change(
727 + array_key_exists('time_saved', $previous) ? $previous['time_saved'] : 0,
728 + 0.0
729 + )
640 730 ];
641 731 }
642 732
643 733 // Calculate feature breakdown and new metrics
@@ -669,20 +759,24 @@
669 759 $most_used_count = $count;
670 760 }
671 761 }
672 762
673 - // Calculate success rate (assuming all logged actions are successful for now)
674 - // In future, we could track failed attempts separately
675 - $success_rate = $total_actions > 0 ? 100 : 0;
763 + // No success rate here on purpose. It used to be
764 + // `$total_actions > 0 ? 100 : 0` — a constant presented as a
765 + // measurement, and one that could only ever read 100% or 0%. Failed
766 + // AI calls are never written to this table, so there is nothing to
767 + // compute a rate from; the KPI card is gone until there is.
676 768
677 769 // Calculate changes from previous period
678 770 $previous_period_data = $this->get_previous_period_data($user_id, $date_condition);
771 + // Note the lack of `?? 0`: a null here means "no previous period",
772 + // and coalescing it to zero would turn that back into a fake 100%.
679 773 $cost_change = $this->calculate_percentage_change(
680 - $previous_period_data['total_cost'] ?? 0,
774 + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0,
681 775 $this->calculate_total_cost($usage_data)
682 776 );
683 777 $time_saved_change = $this->calculate_percentage_change(
684 - $previous_period_data['time_saved'] ?? 0,
778 + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0,
685 779 $this->calculate_time_saved($feature_breakdown)
686 780 );
687 781
688 782 return [
@@ -691,9 +785,8 @@
691 785 'feature_breakdown' => $feature_breakdown,
692 786 'features_used_count' => $features_used_count,
693 787 'most_used_feature' => $most_used_feature,
694 788 'most_used_count' => $most_used_count,
695 - 'success_rate' => $success_rate,
696 789 'usage_data' => $usage_data,
697 790 'cost_change' => $cost_change,
698 791 'time_saved_change' => $time_saved_change
699 792 ];
@@ -735,24 +828,38 @@
735 828 );
736 829 }
737 830
738 831 if (!$result || (int) $result['content_optimized'] === 0) {
832 + // Same reasoning as the empty branch in get_ai_usage_metrics():
833 + // an empty current window is not "no change". A user who
834 + // optimized three posts last month and none this month should
835 + // see -100%, not the em-dash a flat period gets — and for `all`
836 + // there is no previous window, so the change is null.
837 + $previous = $this->get_previous_seo_data($user_id, $date_condition);
838 +
739 839 return [
740 840 'content_optimized' => 0,
741 841 'average_seo_score' => 0,
742 - 'content_optimized_change' => 0,
743 - 'seo_score_change' => 0
842 + 'content_optimized_change' => $this->calculate_percentage_change(
843 + array_key_exists('content_optimized', $previous) ? $previous['content_optimized'] : 0,
844 + 0.0
845 + ),
846 + 'seo_score_change' => $this->calculate_percentage_change(
847 + array_key_exists('average_seo_score', $previous) ? $previous['average_seo_score'] : 0,
848 + 0.0
849 + )
744 850 ];
745 851 }
746 852
747 853 // Calculate changes from previous period
748 854 $previous_seo_data = $this->get_previous_seo_data($user_id, $date_condition);
855 + // As above: no `?? 0`, so a null "no previous period" survives.
749 856 $content_optimized_change = $this->calculate_percentage_change(
750 - $previous_seo_data['content_optimized'] ?? 0,
857 + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0,
751 858 (int) $result['content_optimized']
752 859 );
753 860 $seo_score_change = $this->calculate_percentage_change(
754 - $previous_seo_data['average_seo_score'] ?? 0,
861 + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0,
755 862 round((float) $result['average_score'], 1)
756 863 );
757 864
758 865 return [
@@ -809,12 +916,21 @@
809 916 * @return WP_REST_Response|WP_Error Response object
810 917 */
811 918 public function get_usage_breakdown(WP_REST_Request $request) {
812 919 try {
813 - $user_id = get_current_user_id();
920 + // Mirrors get_overview_metrics(). The two endpoints declared the
921 + // same `user_id` argument but only overview honoured it, so the
922 + // same query string described two different users depending on
923 + // which one you asked. check_permissions() already requires
924 + // manage_options before another user's id is accepted.
925 + $user_id = $request->get_param('user_id') ?: get_current_user_id();
814 926 $period = $request->get_param('period') ?? '30d';
815 - $page = max(1, (int) $request->get_param('page') ?? 1);
816 - $per_page = min(100, max(10, (int) $request->get_param('per_page') ?? 20));
927 + // `(int)` binds tighter than `??`, so `(int) null` is 0 and the
928 + // `?? 20` fallback was unreachable — per_page silently defaulted to
929 + // the max(10, 0) floor of 10 rather than the 20 it advertises, and
930 + // page to max(1, 0) = 1 by luck rather than intent (#394).
931 + $page = max(1, (int) ($request->get_param('page') ?? 1));
932 + $per_page = min(100, max(10, (int) ($request->get_param('per_page') ?? 20)));
817 933 $offset = ($page - 1) * $per_page;
818 934
819 935 // Get date range for queries
820 936 $date_condition = $this->get_date_condition($period);
@@ -830,9 +946,10 @@
830 946 'pagination' => [
831 947 'page' => $page,
832 948 'per_page' => $per_page,
833 949 'total_records' => $total_records,
834 - 'total_pages' => ceil($total_records / $per_page)
950 + // (int) so it serialises as 2, not 2.0.
951 + 'total_pages' => $per_page > 0 ? (int) ceil($total_records / $per_page) : 0
835 952 ],
836 953 'period' => $period
837 954 ]
838 955 ], 200);
@@ -880,14 +997,22 @@
880 997 * @param float $old_value Previous period value
881 998 * @param float $new_value Current period value
882 999 * @return float Percentage change
883 1000 */
884 - private function calculate_percentage_change(float $old_value, float $new_value): float {
1001 + private function calculate_percentage_change($old_value, float $new_value): ?float {
1002 + // No previous period at all (the 'all' range).
1003 + if (null === $old_value) {
1004 + return null;
1005 + }
1006 +
885 1007 if ((float) $old_value === 0.0) {
886 - return $new_value > 0 ? 100 : 0;
1008 + // Growth from nothing has no percentage. Reporting a flat 100%
1009 + // dressed it up as a measured change; null lets the UI say "new"
1010 + // (or say nothing) instead of inventing a number.
1011 + return $new_value > 0 ? null : 0.0;
887 1012 }
888 1013
889 - return round((($new_value - $old_value) / $old_value) * 100, 1);
1014 + return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1);
890 1015 }
891 1016
892 1017 /**
893 1018 * Get previous period data for comparison
@@ -904,8 +1029,14 @@
904 1029
905 1030 // Extract the interval from current date condition to calculate previous period
906 1031 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
907 1032
1033 + // No preceding window: report "not comparable" rather than querying a
1034 + // made-up one.
1035 + if (null === $previous_date_condition) {
1036 + return ['total_cost' => null, 'time_saved' => null];
1037 + }
1038 +
908 1039 // Prepare and execute query with proper parameter binding to prevent SQL injection
909 1040 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
910 1041 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
911 1042 $usage_data = $wpdb->get_results(
@@ -912,9 +1043,10 @@
912 1043 $wpdb->prepare("
913 1044 SELECT
914 1045 provider,
915 1046 action,
916 - tokens_used
1047 + tokens_used,
1048 + metadata
917 1049 FROM `{$table_name}`
918 1050 WHERE user_id = %d
919 1051 {$previous_date_condition}
920 1052 ", $user_id),
@@ -1082,9 +1214,9 @@
1082 1214 // Try specific model first, fallback to default
1083 1215 if ($model && isset(self::GEMINI_PRICING[$model])) {
1084 1216 return self::GEMINI_PRICING[$model];
1085 1217 }
1086 - return self::GEMINI_PRICING['gemini-2.5-flash'] ?? null;
1218 + return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null;
1087 1219
1088 1220 case 'openrouter':
1089 1221 // Try specific model first, fallback to default
1090 1222 if ($model && isset(self::OPENROUTER_PRICING[$model])) {
@@ -1132,8 +1264,13 @@
1132 1264 $table_name = esc_sql($this->database->get_table('seo_scores'));
1133 1265
1134 1266 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1135 1267
1268 + // See get_previous_period_data(): no preceding window, no comparison.
1269 + if (null === $previous_date_condition) {
1270 + return ['content_optimized' => null, 'average_seo_score' => null];
1271 + }
1272 +
1136 1273 // Prepare and execute query with proper parameter binding to prevent SQL injection
1137 1274 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1138 1275 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
1139 1276 $result = $wpdb->get_row(
@@ -1180,12 +1317,18 @@
1180 1317
1181 1318 /**
1182 1319 * Convert current period condition to previous period condition
1183 1320 *
1321 + * Returns null when there is no preceding window to compare against.
1322 + * `all` produces an empty date condition, which used to fall through to a
1323 + * hardcoded 30–60 day fallback — so "all time" was compared against an
1324 + * arbitrary month and reported a large, meaningless increase. "No
1325 + * comparison" is now representable instead of being a parse failure.
1326 + *
1184 1327 * @param string $current_condition Current period SQL condition
1185 - * @return string Previous period SQL condition
1328 + * @return string|null Previous period SQL condition, or null when none exists
1186 1329 */
1187 - private function get_previous_period_condition(string $current_condition): string {
1330 + private function get_previous_period_condition(string $current_condition): ?string {
1188 1331 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1189 1332 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1190 1333 $interval = (int) $matches[1];
1191 1334 $unit = $matches[2];
@@ -1197,9 +1340,9 @@
1197 1340 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1198 1341 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1199 1342 }
1200 1343
1201 - // Fallback for unknown conditions
1202 - return "AND created_at >= DATE_SUB(NOW(), INTERVAL 60 DAY)
1203 - AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)";
1344 + // No interval means no window — 'all'. Comparing every record ever
1345 + // against a fabricated 30-day slice is not a trend.
1346 + return null;
1204 1347 }
1205 1348 }