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 +284 -93 1.28.0 → 2.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 ],
@@ -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,
@@ -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
@@ -502,8 +589,12 @@
502 589 'openai' => 0,
503 590 'claude' => 0,
504 591 'gemini' => 0,
505 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,
506 597 'total' => 0,
507 598 'by_provider' => []
508 599 ];
509 600
@@ -508,46 +599,50 @@
508 599 ];
509 600
510 601 foreach ($usage_data as $usage) {
511 602 $tokens = (int) $usage['tokens_used'];
512 - $provider = $usage['provider'];
603 + $provider = (string) $usage['provider'];
513 604
514 - // Estimate 70% input, 30% output tokens
515 - $input_tokens = $tokens * 0.7;
516 - $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 + }
517 610
518 - $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);
519 619
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 - }
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);
527 623 }
528 624
529 - $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'];
625 + $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'] + $costs['openai_compatible'];
530 626
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 - ];
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 + }
550 645
551 646 return $costs;
552 647 }
553 648
@@ -587,9 +682,9 @@
587 682 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
588 683 $usage_data = $wpdb->get_results(
589 684 $wpdb->prepare(
590 685 // 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",
686 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
592 687 $user_id,
593 688 $cutoff
594 689 ),
595 690 ARRAY_A
@@ -598,9 +693,9 @@
598 693 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
599 694 $usage_data = $wpdb->get_results(
600 695 $wpdb->prepare(
601 696 // 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",
697 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d",
603 698 $user_id
604 699 ),
605 700 ARRAY_A
606 701 );
@@ -606,15 +701,37 @@
606 701 );
607 702 }
608 703
609 704 if (empty($usage_data)) {
705 + // Must return the same shape as the populated path below —
706 + // get_overview_metrics() reads every key unconditionally, so a
707 + // short array here surfaces as undefined-key warnings and null
708 + // fields for any user with no AI usage yet (i.e. a fresh install).
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 +
610 718 return [
611 719 'total_actions' => 0,
612 720 'total_tokens' => 0,
613 721 'feature_breakdown' => [],
722 + 'features_used_count' => 0,
723 + 'most_used_feature' => '',
724 + 'most_used_count' => 0,
614 725 'usage_data' => [],
615 - 'cost_change' => 0,
616 - '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 + )
617 734 ];
618 735 }
619 736
620 737 // Calculate feature breakdown and new metrics
@@ -646,20 +763,24 @@
646 763 $most_used_count = $count;
647 764 }
648 765 }
649 766
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;
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.
653 772
654 773 // Calculate changes from previous period
655 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%.
656 777 $cost_change = $this->calculate_percentage_change(
657 - $previous_period_data['total_cost'] ?? 0,
778 + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0,
658 779 $this->calculate_total_cost($usage_data)
659 780 );
660 781 $time_saved_change = $this->calculate_percentage_change(
661 - $previous_period_data['time_saved'] ?? 0,
782 + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0,
662 783 $this->calculate_time_saved($feature_breakdown)
663 784 );
664 785
665 786 return [
@@ -668,9 +789,8 @@
668 789 'feature_breakdown' => $feature_breakdown,
669 790 'features_used_count' => $features_used_count,
670 791 'most_used_feature' => $most_used_feature,
671 792 'most_used_count' => $most_used_count,
672 - 'success_rate' => $success_rate,
673 793 'usage_data' => $usage_data,
674 794 'cost_change' => $cost_change,
675 795 'time_saved_change' => $time_saved_change
676 796 ];
@@ -711,25 +831,39 @@
711 831 ARRAY_A
712 832 );
713 833 }
714 834
715 - if (!$result || $result['content_optimized'] == 0) {
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 +
716 843 return [
717 844 'content_optimized' => 0,
718 845 'average_seo_score' => 0,
719 - 'content_optimized_change' => 0,
720 - '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 + )
721 854 ];
722 855 }
723 856
724 857 // Calculate changes from previous period
725 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.
726 860 $content_optimized_change = $this->calculate_percentage_change(
727 - $previous_seo_data['content_optimized'] ?? 0,
861 + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0,
728 862 (int) $result['content_optimized']
729 863 );
730 864 $seo_score_change = $this->calculate_percentage_change(
731 - $previous_seo_data['average_seo_score'] ?? 0,
865 + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0,
732 866 round((float) $result['average_score'], 1)
733 867 );
734 868
735 869 return [
@@ -784,14 +918,23 @@
784 918 *
785 919 * @param WP_REST_Request $request Request object
786 920 * @return WP_REST_Response|WP_Error Response object
787 921 */
788 - public function get_usage_breakdown(WP_REST_Request $request): WP_REST_Response|WP_Error {
922 + public function get_usage_breakdown(WP_REST_Request $request) {
789 923 try {
790 - $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();
791 930 $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));
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)));
794 937 $offset = ($page - 1) * $per_page;
795 938
796 939 // Get date range for queries
797 940 $date_condition = $this->get_date_condition($period);
@@ -807,9 +950,10 @@
807 950 'pagination' => [
808 951 'page' => $page,
809 952 'per_page' => $per_page,
810 953 'total_records' => $total_records,
811 - '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
812 956 ],
813 957 'period' => $period
814 958 ]
815 959 ], 200);
@@ -828,9 +972,9 @@
828 972 *
829 973 * @param WP_REST_Request $request Request object
830 974 * @return WP_REST_Response|WP_Error Response object
831 975 */
832 - public function get_cost_analysis(WP_REST_Request $request): WP_REST_Response|WP_Error {
976 + public function get_cost_analysis(WP_REST_Request $request) {
833 977 // Return 200 with success:false so the frontend can render an
834 978 // "unavailable" state — apiFetch rejects on non-2xx, which would
835 979 // otherwise surface as a generic hard error.
836 980 return new WP_REST_Response([
@@ -857,14 +1001,22 @@
857 1001 * @param float $old_value Previous period value
858 1002 * @param float $new_value Current period value
859 1003 * @return float Percentage change
860 1004 */
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;
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;
864 1009 }
865 1010
866 - return round((($new_value - $old_value) / $old_value) * 100, 1);
1011 + if ((float) $old_value === 0.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;
1016 + }
1017 +
1018 + return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1);
867 1019 }
868 1020
869 1021 /**
870 1022 * Get previous period data for comparison
@@ -881,8 +1033,14 @@
881 1033
882 1034 // Extract the interval from current date condition to calculate previous period
883 1035 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
884 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 +
885 1043 // Prepare and execute query with proper parameter binding to prevent SQL injection
886 1044 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
887 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
888 1046 $usage_data = $wpdb->get_results(
@@ -889,9 +1047,10 @@
889 1047 $wpdb->prepare("
890 1048 SELECT
891 1049 provider,
892 1050 action,
893 - tokens_used
1051 + tokens_used,
1052 + metadata
894 1053 FROM `{$table_name}`
895 1054 WHERE user_id = %d
896 1055 {$previous_date_condition}
897 1056 ", $user_id),
@@ -1027,9 +1186,9 @@
1027 1186 $input_tokens = $tokens_used * 0.7;
1028 1187 $output_tokens = $tokens_used * 0.3;
1029 1188
1030 1189 return (($input_tokens / 1000000) * $pricing['input']) +
1031 - (($output_tokens / 1000000) * $pricing['output']);
1190 + (($output_tokens / 1000000) * $pricing['output']);
1032 1191 }
1033 1192
1034 1193 /**
1035 1194 * Get pricing for any model with intelligent fallbacks
@@ -1052,9 +1211,9 @@
1052 1211 if ($model && isset(self::CLAUDE_PRICING[$model])) {
1053 1212 return self::CLAUDE_PRICING[$model];
1054 1213 }
1055 1214 return self::CLAUDE_PRICING['claude-sonnet-5'] ??
1056 - self::CLAUDE_PRICING['claude-sonnet-4-6'] ?? null;
1215 + self::CLAUDE_PRICING['claude-sonnet-4-6'] ?? null;
1057 1216
1058 1217 case 'gemini':
1059 1218 // Try specific model first, fallback to default
1060 1219 if ($model && isset(self::GEMINI_PRICING[$model])) {
@@ -1059,9 +1218,9 @@
1059 1218 // Try specific model first, fallback to default
1060 1219 if ($model && isset(self::GEMINI_PRICING[$model])) {
1061 1220 return self::GEMINI_PRICING[$model];
1062 1221 }
1063 - return self::GEMINI_PRICING['gemini-2.5-flash'] ?? null;
1222 + return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null;
1064 1223
1065 1224 case 'openrouter':
1066 1225 // Try specific model first, fallback to default
1067 1226 if ($model && isset(self::OPENROUTER_PRICING[$model])) {
@@ -1068,8 +1227,22 @@
1068 1227 return self::OPENROUTER_PRICING[$model];
1069 1228 }
1070 1229 return self::OPENROUTER_PRICING['openai/gpt-4o-mini'] ?? null;
1071 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 +
1072 1245 default:
1073 1246 return null;
1074 1247 }
1075 1248 }
@@ -1089,8 +1262,11 @@
1089 1262 case 'gemini':
1090 1263 return \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL;
1091 1264 case 'openrouter':
1092 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', '');
1093 1269 default:
1094 1270 return 'unknown';
1095 1271 }
1096 1272 }
@@ -1109,8 +1285,13 @@
1109 1285 $table_name = esc_sql($this->database->get_table('seo_scores'));
1110 1286
1111 1287 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1112 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 +
1113 1294 // Prepare and execute query with proper parameter binding to prevent SQL injection
1114 1295 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1115 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
1116 1297 $result = $wpdb->get_row(
@@ -1143,12 +1324,16 @@
1143 1324 * @param string $condition Legacy date condition string
1144 1325 * @return string Period key
1145 1326 */
1146 1327 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';
1328 + if (strpos($condition, 'INTERVAL 7') !== false) { return '7d';
1329 + }
1330 + if (strpos($condition, 'INTERVAL 30') !== false) { return '30d';
1331 + }
1332 + if (strpos($condition, 'INTERVAL 90') !== false) { return '90d';
1333 + }
1334 + if (empty(trim($condition))) { return 'all';
1335 + }
1151 1336 return '30d';
1152 1337 }
1153 1338
1154 1339 /**
@@ -1153,12 +1338,18 @@
1153 1338
1154 1339 /**
1155 1340 * Convert current period condition to previous period condition
1156 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 + *
1157 1348 * @param string $current_condition Current period SQL condition
1158 - * @return string Previous period SQL condition
1349 + * @return string|null Previous period SQL condition, or null when none exists
1159 1350 */
1160 - private function get_previous_period_condition(string $current_condition): string {
1351 + private function get_previous_period_condition(string $current_condition): ?string {
1161 1352 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1162 1353 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1163 1354 $interval = (int) $matches[1];
1164 1355 $unit = $matches[2];
@@ -1170,9 +1361,9 @@
1170 1361 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1171 1362 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1172 1363 }
1173 1364
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)";
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;
1177 1368 }
1178 1369 }