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 +261 -91 1.28.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' => [
@@ -314,9 +347,9 @@
314 347 *
315 348 * @param WP_REST_Request $request Request object
316 349 * @return WP_REST_Response|WP_Error Response object
317 350 */
318 - public function get_overview_metrics(WP_REST_Request $request): WP_REST_Response|WP_Error {
351 + public function get_overview_metrics(WP_REST_Request $request) {
319 352 $period = $request->get_param('period');
320 353 $user_id = $request->get_param('user_id') ?: get_current_user_id();
321 354
322 355 try {
@@ -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 ],
@@ -388,9 +420,9 @@
388 420 *
389 421 * @param WP_REST_Request $request Request object
390 422 * @return bool|WP_Error Permission result
391 423 */
392 - public function check_permissions(WP_REST_Request $request): bool|WP_Error {
424 + public function check_permissions(WP_REST_Request $request) {
393 425 // Check if user is logged in
394 426 if (!is_user_logged_in()) {
395 427 return new WP_Error(
396 428 'not_logged_in',
@@ -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
@@ -458,15 +531,24 @@
458 531 * @param string $period Period string
459 532 * @return string|null Cutoff datetime in MySQL format, or null for all time
460 533 */
461 534 private function get_date_cutoff(string $period): ?string {
462 - $days = match($period) {
463 - '7d' => 7,
464 - '30d' => 30,
465 - '90d' => 90,
466 - 'all' => null,
467 - default => 30,
468 - };
535 + switch ($period) {
536 + case '7d':
537 + $days = 7;
538 + break;
539 + case '30d':
540 + $days = 30;
541 + break;
542 + case '90d':
543 + $days = 90;
544 + break;
545 + case 'all':
546 + $days = null;
547 + break;
548 + default:
549 + $days = 30;
550 + }
469 551 if ($days === null) {
470 552 return null;
471 553 }
472 554 return gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
@@ -481,15 +563,20 @@
481 563 * @param string $period Period string
482 564 * @return string SQL date condition
483 565 */
484 566 private function get_date_condition(string $period): string {
485 - return match($period) {
486 - '7d' => "AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)",
487 - '30d' => "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)",
488 - '90d' => "AND created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)",
489 - 'all' => "",
490 - default => "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
491 - };
567 + switch ($period) {
568 + case '7d':
569 + return "AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
570 + case '30d':
571 + return "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)";
572 + case '90d':
573 + return "AND created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)";
574 + case 'all':
575 + return "";
576 + default:
577 + return "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)";
578 + }
492 579 }
493 580
494 581 /**
495 582 * Calculate costs from usage data
@@ -508,46 +595,50 @@
508 595 ];
509 596
510 597 foreach ($usage_data as $usage) {
511 598 $tokens = (int) $usage['tokens_used'];
512 - $provider = $usage['provider'];
599 + $provider = (string) $usage['provider'];
513 600
514 - // Estimate 70% input, 30% output tokens
515 - $input_tokens = $tokens * 0.7;
516 - $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 + }
517 606
518 - $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);
519 615
520 - // Use the robust pricing helper for consistent cost calculation
521 - $pricing = $this->get_model_pricing($provider);
522 - if ($pricing) {
523 - $cost = ($input_tokens * $pricing['input'] / 1000000) +
524 - ($output_tokens * $pricing['output'] / 1000000);
525 - $costs[$provider] += $cost;
526 - }
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);
527 619 }
528 620
529 621 $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'];
530 622
531 - // Format provider breakdown
532 - $costs['by_provider'] = [
533 - 'openai' => [
534 - 'cost' => round($costs['openai'], 4),
535 - 'percentage' => $costs['total'] > 0 ? round(($costs['openai'] / $costs['total']) * 100, 1) : 0
536 - ],
537 - 'claude' => [
538 - 'cost' => round($costs['claude'], 4),
539 - 'percentage' => $costs['total'] > 0 ? round(($costs['claude'] / $costs['total']) * 100, 1) : 0
540 - ],
541 - 'gemini' => [
542 - 'cost' => round($costs['gemini'], 4),
543 - 'percentage' => $costs['total'] > 0 ? round(($costs['gemini'] / $costs['total']) * 100, 1) : 0
544 - ],
545 - 'openrouter' => [
546 - 'cost' => round($costs['openrouter'], 4),
547 - 'percentage' => $costs['total'] > 0 ? round(($costs['openrouter'] / $costs['total']) * 100, 1) : 0
548 - ]
549 - ];
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 + }
550 641
551 642 return $costs;
552 643 }
553 644
@@ -587,9 +678,9 @@
587 678 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
588 679 $usage_data = $wpdb->get_results(
589 680 $wpdb->prepare(
590 681 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
591 - "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",
592 683 $user_id,
593 684 $cutoff
594 685 ),
595 686 ARRAY_A
@@ -598,9 +689,9 @@
598 689 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
599 690 $usage_data = $wpdb->get_results(
600 691 $wpdb->prepare(
601 692 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
602 - "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",
603 694 $user_id
604 695 ),
605 696 ARRAY_A
606 697 );
@@ -606,15 +697,37 @@
606 697 );
607 698 }
608 699
609 700 if (empty($usage_data)) {
701 + // Must return the same shape as the populated path below —
702 + // get_overview_metrics() reads every key unconditionally, so a
703 + // short array here surfaces as undefined-key warnings and null
704 + // fields for any user with no AI usage yet (i.e. a fresh install).
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 +
610 714 return [
611 715 'total_actions' => 0,
612 716 'total_tokens' => 0,
613 717 'feature_breakdown' => [],
718 + 'features_used_count' => 0,
719 + 'most_used_feature' => '',
720 + 'most_used_count' => 0,
614 721 'usage_data' => [],
615 - 'cost_change' => 0,
616 - '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 + )
617 730 ];
618 731 }
619 732
620 733 // Calculate feature breakdown and new metrics
@@ -646,20 +759,24 @@
646 759 $most_used_count = $count;
647 760 }
648 761 }
649 762
650 - // Calculate success rate (assuming all logged actions are successful for now)
651 - // In future, we could track failed attempts separately
652 - $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.
653 768
654 769 // Calculate changes from previous period
655 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%.
656 773 $cost_change = $this->calculate_percentage_change(
657 - $previous_period_data['total_cost'] ?? 0,
774 + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0,
658 775 $this->calculate_total_cost($usage_data)
659 776 );
660 777 $time_saved_change = $this->calculate_percentage_change(
661 - $previous_period_data['time_saved'] ?? 0,
778 + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0,
662 779 $this->calculate_time_saved($feature_breakdown)
663 780 );
664 781
665 782 return [
@@ -668,9 +785,8 @@
668 785 'feature_breakdown' => $feature_breakdown,
669 786 'features_used_count' => $features_used_count,
670 787 'most_used_feature' => $most_used_feature,
671 788 'most_used_count' => $most_used_count,
672 - 'success_rate' => $success_rate,
673 789 'usage_data' => $usage_data,
674 790 'cost_change' => $cost_change,
675 791 'time_saved_change' => $time_saved_change
676 792 ];
@@ -711,25 +827,39 @@
711 827 ARRAY_A
712 828 );
713 829 }
714 830
715 - if (!$result || $result['content_optimized'] == 0) {
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 +
716 839 return [
717 840 'content_optimized' => 0,
718 841 'average_seo_score' => 0,
719 - 'content_optimized_change' => 0,
720 - '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 + )
721 850 ];
722 851 }
723 852
724 853 // Calculate changes from previous period
725 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.
726 856 $content_optimized_change = $this->calculate_percentage_change(
727 - $previous_seo_data['content_optimized'] ?? 0,
857 + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0,
728 858 (int) $result['content_optimized']
729 859 );
730 860 $seo_score_change = $this->calculate_percentage_change(
731 - $previous_seo_data['average_seo_score'] ?? 0,
861 + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0,
732 862 round((float) $result['average_score'], 1)
733 863 );
734 864
735 865 return [
@@ -784,14 +914,23 @@
784 914 *
785 915 * @param WP_REST_Request $request Request object
786 916 * @return WP_REST_Response|WP_Error Response object
787 917 */
788 - public function get_usage_breakdown(WP_REST_Request $request): WP_REST_Response|WP_Error {
918 + public function get_usage_breakdown(WP_REST_Request $request) {
789 919 try {
790 - $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();
791 926 $period = $request->get_param('period') ?? '30d';
792 - $page = max(1, (int) $request->get_param('page') ?? 1);
793 - $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)));
794 933 $offset = ($page - 1) * $per_page;
795 934
796 935 // Get date range for queries
797 936 $date_condition = $this->get_date_condition($period);
@@ -807,9 +946,10 @@
807 946 'pagination' => [
808 947 'page' => $page,
809 948 'per_page' => $per_page,
810 949 'total_records' => $total_records,
811 - '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
812 952 ],
813 953 'period' => $period
814 954 ]
815 955 ], 200);
@@ -828,9 +968,9 @@
828 968 *
829 969 * @param WP_REST_Request $request Request object
830 970 * @return WP_REST_Response|WP_Error Response object
831 971 */
832 - public function get_cost_analysis(WP_REST_Request $request): WP_REST_Response|WP_Error {
972 + public function get_cost_analysis(WP_REST_Request $request) {
833 973 // Return 200 with success:false so the frontend can render an
834 974 // "unavailable" state — apiFetch rejects on non-2xx, which would
835 975 // otherwise surface as a generic hard error.
836 976 return new WP_REST_Response([
@@ -857,14 +997,22 @@
857 997 * @param float $old_value Previous period value
858 998 * @param float $new_value Current period value
859 999 * @return float Percentage change
860 1000 */
861 - private function calculate_percentage_change(float $old_value, float $new_value): float {
862 - if ($old_value == 0) {
863 - return $new_value > 0 ? 100 : 0;
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;
864 1005 }
865 1006
866 - return round((($new_value - $old_value) / $old_value) * 100, 1);
1007 + if ((float) $old_value === 0.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;
1012 + }
1013 +
1014 + return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1);
867 1015 }
868 1016
869 1017 /**
870 1018 * Get previous period data for comparison
@@ -881,8 +1029,14 @@
881 1029
882 1030 // Extract the interval from current date condition to calculate previous period
883 1031 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
884 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 +
885 1039 // Prepare and execute query with proper parameter binding to prevent SQL injection
886 1040 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
887 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
888 1042 $usage_data = $wpdb->get_results(
@@ -889,9 +1043,10 @@
889 1043 $wpdb->prepare("
890 1044 SELECT
891 1045 provider,
892 1046 action,
893 - tokens_used
1047 + tokens_used,
1048 + metadata
894 1049 FROM `{$table_name}`
895 1050 WHERE user_id = %d
896 1051 {$previous_date_condition}
897 1052 ", $user_id),
@@ -1027,9 +1182,9 @@
1027 1182 $input_tokens = $tokens_used * 0.7;
1028 1183 $output_tokens = $tokens_used * 0.3;
1029 1184
1030 1185 return (($input_tokens / 1000000) * $pricing['input']) +
1031 - (($output_tokens / 1000000) * $pricing['output']);
1186 + (($output_tokens / 1000000) * $pricing['output']);
1032 1187 }
1033 1188
1034 1189 /**
1035 1190 * Get pricing for any model with intelligent fallbacks
@@ -1052,9 +1207,9 @@
1052 1207 if ($model && isset(self::CLAUDE_PRICING[$model])) {
1053 1208 return self::CLAUDE_PRICING[$model];
1054 1209 }
1055 1210 return self::CLAUDE_PRICING['claude-sonnet-5'] ??
1056 - self::CLAUDE_PRICING['claude-sonnet-4-6'] ?? null;
1211 + self::CLAUDE_PRICING['claude-sonnet-4-6'] ?? null;
1057 1212
1058 1213 case 'gemini':
1059 1214 // Try specific model first, fallback to default
1060 1215 if ($model && isset(self::GEMINI_PRICING[$model])) {
@@ -1059,9 +1214,9 @@
1059 1214 // Try specific model first, fallback to default
1060 1215 if ($model && isset(self::GEMINI_PRICING[$model])) {
1061 1216 return self::GEMINI_PRICING[$model];
1062 1217 }
1063 - return self::GEMINI_PRICING['gemini-2.5-flash'] ?? null;
1218 + return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null;
1064 1219
1065 1220 case 'openrouter':
1066 1221 // Try specific model first, fallback to default
1067 1222 if ($model && isset(self::OPENROUTER_PRICING[$model])) {
@@ -1109,8 +1264,13 @@
1109 1264 $table_name = esc_sql($this->database->get_table('seo_scores'));
1110 1265
1111 1266 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1112 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 +
1113 1273 // Prepare and execute query with proper parameter binding to prevent SQL injection
1114 1274 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1115 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
1116 1276 $result = $wpdb->get_row(
@@ -1143,12 +1303,16 @@
1143 1303 * @param string $condition Legacy date condition string
1144 1304 * @return string Period key
1145 1305 */
1146 1306 private function resolve_period_from_condition(string $condition): string {
1147 - if (strpos($condition, 'INTERVAL 7') !== false) return '7d';
1148 - if (strpos($condition, 'INTERVAL 30') !== false) return '30d';
1149 - if (strpos($condition, 'INTERVAL 90') !== false) return '90d';
1150 - if (empty(trim($condition))) return 'all';
1307 + if (strpos($condition, 'INTERVAL 7') !== false) { return '7d';
1308 + }
1309 + if (strpos($condition, 'INTERVAL 30') !== false) { return '30d';
1310 + }
1311 + if (strpos($condition, 'INTERVAL 90') !== false) { return '90d';
1312 + }
1313 + if (empty(trim($condition))) { return 'all';
1314 + }
1151 1315 return '30d';
1152 1316 }
1153 1317
1154 1318 /**
@@ -1153,12 +1317,18 @@
1153 1317
1154 1318 /**
1155 1319 * Convert current period condition to previous period condition
1156 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 + *
1157 1327 * @param string $current_condition Current period SQL condition
1158 - * @return string Previous period SQL condition
1328 + * @return string|null Previous period SQL condition, or null when none exists
1159 1329 */
1160 - private function get_previous_period_condition(string $current_condition): string {
1330 + private function get_previous_period_condition(string $current_condition): ?string {
1161 1331 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1162 1332 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1163 1333 $interval = (int) $matches[1];
1164 1334 $unit = $matches[2];
@@ -1170,9 +1340,9 @@
1170 1340 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1171 1341 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1172 1342 }
1173 1343
1174 - // Fallback for unknown conditions
1175 - return "AND created_at >= DATE_SUB(NOW(), INTERVAL 60 DAY)
1176 - 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;
1177 1347 }
1178 1348 }