PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/api/class-usage-analytics-endpoint.php +232 -68 2.0.0 → 2.10.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,14 +322,16 @@
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',
300 - 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter'],
332 + 'type' => 'string',
333 + 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'],
301 334 'sanitize_callback' => 'sanitize_key'
302 335 ],
303 336 'user_id' => [
304 337 'default' => 0,
@@ -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
@@ -516,8 +589,12 @@
516 589 'openai' => 0,
517 590 'claude' => 0,
518 591 'gemini' => 0,
519 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,
520 597 'total' => 0,
521 598 'by_provider' => []
522 599 ];
523 600
@@ -522,46 +599,50 @@
522 599 ];
523 600
524 601 foreach ($usage_data as $usage) {
525 602 $tokens = (int) $usage['tokens_used'];
526 - $provider = $usage['provider'];
603 + $provider = (string) $usage['provider'];
527 604
528 - // Estimate 70% input, 30% output tokens
529 - $input_tokens = $tokens * 0.7;
530 - $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 + }
531 610
532 - $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);
533 619
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 - }
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);
541 623 }
542 624
543 - $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'];
625 + $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'] + $costs['openai_compatible'];
544 626
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 - ];
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 + }
564 645
565 646 return $costs;
566 647 }
567 648
@@ -601,9 +682,9 @@
601 682 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
602 683 $usage_data = $wpdb->get_results(
603 684 $wpdb->prepare(
604 685 // 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",
686 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
606 687 $user_id,
607 688 $cutoff
608 689 ),
609 690 ARRAY_A
@@ -612,9 +693,9 @@
612 693 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
613 694 $usage_data = $wpdb->get_results(
614 695 $wpdb->prepare(
615 696 // 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",
697 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d",
617 698 $user_id
618 699 ),
619 700 ARRAY_A
620 701 );
@@ -625,8 +706,16 @@
625 706 // get_overview_metrics() reads every key unconditionally, so a
626 707 // short array here surfaces as undefined-key warnings and null
627 708 // fields for any user with no AI usage yet (i.e. a fresh install).
628 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 +
629 718 return [
630 719 'total_actions' => 0,
631 720 'total_tokens' => 0,
632 721 'feature_breakdown' => [],
@@ -632,12 +721,17 @@
632 721 'feature_breakdown' => [],
633 722 'features_used_count' => 0,
634 723 'most_used_feature' => '',
635 724 'most_used_count' => 0,
636 - 'success_rate' => 0,
637 725 'usage_data' => [],
638 - 'cost_change' => 0,
639 - '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 + )
640 734 ];
641 735 }
642 736
643 737 // Calculate feature breakdown and new metrics
@@ -669,20 +763,24 @@
669 763 $most_used_count = $count;
670 764 }
671 765 }
672 766
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;
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.
676 772
677 773 // Calculate changes from previous period
678 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%.
679 777 $cost_change = $this->calculate_percentage_change(
680 - $previous_period_data['total_cost'] ?? 0,
778 + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0,
681 779 $this->calculate_total_cost($usage_data)
682 780 );
683 781 $time_saved_change = $this->calculate_percentage_change(
684 - $previous_period_data['time_saved'] ?? 0,
782 + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0,
685 783 $this->calculate_time_saved($feature_breakdown)
686 784 );
687 785
688 786 return [
@@ -691,9 +789,8 @@
691 789 'feature_breakdown' => $feature_breakdown,
692 790 'features_used_count' => $features_used_count,
693 791 'most_used_feature' => $most_used_feature,
694 792 'most_used_count' => $most_used_count,
695 - 'success_rate' => $success_rate,
696 793 'usage_data' => $usage_data,
697 794 'cost_change' => $cost_change,
698 795 'time_saved_change' => $time_saved_change
699 796 ];
@@ -735,24 +832,38 @@
735 832 );
736 833 }
737 834
738 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 +
739 843 return [
740 844 'content_optimized' => 0,
741 845 'average_seo_score' => 0,
742 - 'content_optimized_change' => 0,
743 - '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 + )
744 854 ];
745 855 }
746 856
747 857 // Calculate changes from previous period
748 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.
749 860 $content_optimized_change = $this->calculate_percentage_change(
750 - $previous_seo_data['content_optimized'] ?? 0,
861 + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0,
751 862 (int) $result['content_optimized']
752 863 );
753 864 $seo_score_change = $this->calculate_percentage_change(
754 - $previous_seo_data['average_seo_score'] ?? 0,
865 + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0,
755 866 round((float) $result['average_score'], 1)
756 867 );
757 868
758 869 return [
@@ -809,12 +920,21 @@
809 920 * @return WP_REST_Response|WP_Error Response object
810 921 */
811 922 public function get_usage_breakdown(WP_REST_Request $request) {
812 923 try {
813 - $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();
814 930 $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));
931 + // `(int)` binds tighter than `??`, so `(int) null` is 0 and the
932 + // `?? 20` fallback was unreachable — per_page silently defaulted to
933 + // the max(10, 0) floor of 10 rather than the 20 it advertises, and
934 + // page to max(1, 0) = 1 by luck rather than intent (#394).
935 + $page = max(1, (int) ($request->get_param('page') ?? 1));
936 + $per_page = min(100, max(10, (int) ($request->get_param('per_page') ?? 20)));
817 937 $offset = ($page - 1) * $per_page;
818 938
819 939 // Get date range for queries
820 940 $date_condition = $this->get_date_condition($period);
@@ -830,9 +950,10 @@
830 950 'pagination' => [
831 951 'page' => $page,
832 952 'per_page' => $per_page,
833 953 'total_records' => $total_records,
834 - '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
835 956 ],
836 957 'period' => $period
837 958 ]
838 959 ], 200);
@@ -880,14 +1001,22 @@
880 1001 * @param float $old_value Previous period value
881 1002 * @param float $new_value Current period value
882 1003 * @return float Percentage change
883 1004 */
884 - 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 +
885 1011 if ((float) $old_value === 0.0) {
886 - 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;
887 1016 }
888 1017
889 - return round((($new_value - $old_value) / $old_value) * 100, 1);
1018 + return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1);
890 1019 }
891 1020
892 1021 /**
893 1022 * Get previous period data for comparison
@@ -904,8 +1033,14 @@
904 1033
905 1034 // Extract the interval from current date condition to calculate previous period
906 1035 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
907 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 +
908 1043 // Prepare and execute query with proper parameter binding to prevent SQL injection
909 1044 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
910 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
911 1046 $usage_data = $wpdb->get_results(
@@ -912,9 +1047,10 @@
912 1047 $wpdb->prepare("
913 1048 SELECT
914 1049 provider,
915 1050 action,
916 - tokens_used
1051 + tokens_used,
1052 + metadata
917 1053 FROM `{$table_name}`
918 1054 WHERE user_id = %d
919 1055 {$previous_date_condition}
920 1056 ", $user_id),
@@ -1082,9 +1218,9 @@
1082 1218 // Try specific model first, fallback to default
1083 1219 if ($model && isset(self::GEMINI_PRICING[$model])) {
1084 1220 return self::GEMINI_PRICING[$model];
1085 1221 }
1086 - return self::GEMINI_PRICING['gemini-2.5-flash'] ?? null;
1222 + return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null;
1087 1223
1088 1224 case 'openrouter':
1089 1225 // Try specific model first, fallback to default
1090 1226 if ($model && isset(self::OPENROUTER_PRICING[$model])) {
@@ -1091,8 +1227,22 @@
1091 1227 return self::OPENROUTER_PRICING[$model];
1092 1228 }
1093 1229 return self::OPENROUTER_PRICING['openai/gpt-4o-mini'] ?? null;
1094 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 +
1095 1245 default:
1096 1246 return null;
1097 1247 }
1098 1248 }
@@ -1112,8 +1262,11 @@
1112 1262 case 'gemini':
1113 1263 return \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL;
1114 1264 case 'openrouter':
1115 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', '');
1116 1269 default:
1117 1270 return 'unknown';
1118 1271 }
1119 1272 }
@@ -1132,8 +1285,13 @@
1132 1285 $table_name = esc_sql($this->database->get_table('seo_scores'));
1133 1286
1134 1287 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1135 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 +
1136 1294 // Prepare and execute query with proper parameter binding to prevent SQL injection
1137 1295 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1138 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
1139 1297 $result = $wpdb->get_row(
@@ -1180,12 +1338,18 @@
1180 1338
1181 1339 /**
1182 1340 * Convert current period condition to previous period condition
1183 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 + *
1184 1348 * @param string $current_condition Current period SQL condition
1185 - * @return string Previous period SQL condition
1349 + * @return string|null Previous period SQL condition, or null when none exists
1186 1350 */
1187 - private function get_previous_period_condition(string $current_condition): string {
1351 + private function get_previous_period_condition(string $current_condition): ?string {
1188 1352 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1189 1353 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1190 1354 $interval = (int) $matches[1];
1191 1355 $unit = $matches[2];
@@ -1197,9 +1361,9 @@
1197 1361 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1198 1362 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1199 1363 }
1200 1364
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)";
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;
1204 1368 }
1205 1369 }