| @@ -94,8 +94,12 @@ | ||
| 94 | 94 | * Model IDs sourced from https://docs.anthropic.com/en/docs/about-claude/models |
| 95 | 95 | */ |
| 96 | 96 | private const CLAUDE_PRICING = [ |
| 97 | 97 | // Current models (recommended) |
| 98 | + 'claude-opus-5' => [ | |
| 99 | + 'input' => 5.00, | |
| 100 | + 'output' => 25.00 | |
| 101 | + ], | |
| 98 | 102 | 'claude-opus-4-8' => [ |
| 99 | 103 | 'input' => 5.00, |
| 100 | 104 | 'output' => 25.00 |
| 101 | 105 | ], |
| @@ -144,8 +148,13 @@ | ||
| 144 | 148 | 'gemini-3.1-pro' => [ |
| 145 | 149 | 'input' => 2.00, |
| 146 | 150 | 'output' => 12.00 |
| 147 | 151 | ], |
| 152 | + // The id the UI offers; 3.1 Pro ships only under -preview. | |
| 153 | + 'gemini-3.1-pro-preview' => [ | |
| 154 | + 'input' => 2.00, | |
| 155 | + 'output' => 12.00 | |
| 156 | + ], | |
| 148 | 157 | 'gemini-3.5-flash' => [ |
| 149 | 158 | 'input' => 1.50, |
| 150 | 159 | 'output' => 9.00 |
| 151 | 160 | ], |
| @@ -192,8 +201,17 @@ | ||
| 192 | 201 | 'openai/gpt-4o-mini' => [ |
| 193 | 202 | 'input' => 0.15, |
| 194 | 203 | 'output' => 0.60 |
| 195 | 204 | ], |
| 205 | + 'anthropic/claude-sonnet-5' => [ | |
| 206 | + 'input' => 3.00, | |
| 207 | + 'output' => 15.00 | |
| 208 | + ], | |
| 209 | + 'google/gemini-3.5-flash' => [ | |
| 210 | + 'input' => 1.50, | |
| 211 | + 'output' => 9.00 | |
| 212 | + ], | |
| 213 | + // Retired upstream, kept so historical usage rows still price correctly. | |
| 196 | 214 | 'anthropic/claude-3.5-sonnet' => [ |
| 197 | 215 | 'input' => 3.00, |
| 198 | 216 | 'output' => 15.00 |
| 199 | 217 | ], |
| @@ -272,18 +290,28 @@ | ||
| 272 | 290 | 'type' => 'string', |
| 273 | 291 | 'enum' => ['7d', '30d', '90d', 'all'], |
| 274 | 292 | 'sanitize_callback' => 'sanitize_key' |
| 275 | 293 | ], |
| 276 | - 'group_by' => [ | |
| 277 | - 'default' => 'day', | |
| 278 | - 'type' => 'string', | |
| 279 | - 'enum' => ['day', 'week', 'month'], | |
| 280 | - 'sanitize_callback' => 'sanitize_key' | |
| 281 | - ], | |
| 282 | 294 | 'user_id' => [ |
| 283 | 295 | 'default' => 0, |
| 284 | 296 | 'type' => 'integer', |
| 285 | 297 | 'sanitize_callback' => 'absint' |
| 298 | + ], | |
| 299 | + // Declared because the handler reads them. They were validated | |
| 300 | + // only by the handler's own clamping, so they had no type | |
| 301 | + // coercion and did not appear in the endpoint's schema. | |
| 302 | + 'page' => [ | |
| 303 | + 'default' => 1, | |
| 304 | + 'type' => 'integer', | |
| 305 | + 'minimum' => 1, | |
| 306 | + 'sanitize_callback' => 'absint' | |
| 307 | + ], | |
| 308 | + 'per_page' => [ | |
| 309 | + 'default' => 20, | |
| 310 | + 'type' => 'integer', | |
| 311 | + 'minimum' => 10, | |
| 312 | + 'maximum' => 100, | |
| 313 | + 'sanitize_callback' => 'absint' | |
| 286 | 314 | ] |
| 287 | 315 | ] |
| 288 | 316 | ]); |
| 289 | 317 | |
| @@ -362,9 +390,8 @@ | ||
| 362 | 390 | 'ai_actions' => $ai_metrics['total_actions'], |
| 363 | 391 | 'features_used_count' => $ai_metrics['features_used_count'], |
| 364 | 392 | 'most_used_feature' => $ai_metrics['most_used_feature'], |
| 365 | 393 | 'most_used_count' => $ai_metrics['most_used_count'], |
| 366 | - 'success_rate' => $ai_metrics['success_rate'], | |
| 367 | 394 | 'content_briefs' => $brief_metrics['total_briefs'], |
| 368 | 395 | 'feature_breakdown' => $ai_metrics['feature_breakdown'], |
| 369 | 396 | 'provider_breakdown' => $cost_data['by_provider'] |
| 370 | 397 | ], |
| @@ -428,8 +455,34 @@ | ||
| 428 | 455 | return true; |
| 429 | 456 | } |
| 430 | 457 | |
| 431 | 458 | /** |
| 459 | + * Bind the cache-invalidation listeners for the whole request lifecycle. | |
| 460 | + * | |
| 461 | + * The listeners used to be registered only by the constructor, which runs | |
| 462 | + * on rest_api_init — so usage logged during cron, WP-CLI or an admin-post | |
| 463 | + * request found no listener and the cached overview rode out its full TTL. | |
| 464 | + * Called from API\Manager::init() on every request instead. | |
| 465 | + * | |
| 466 | + * @since 2.2.1 | |
| 467 | + * @return void | |
| 468 | + */ | |
| 469 | + public static function boot_cache_invalidation(): void { | |
| 470 | + static $booted = false; | |
| 471 | + | |
| 472 | + if ($booted) { | |
| 473 | + return; | |
| 474 | + } | |
| 475 | + | |
| 476 | + $booted = true; | |
| 477 | + | |
| 478 | + // Constructing the endpoint registers the listeners; the guard in | |
| 479 | + // setup_cache_invalidation() keeps a later REST construction from | |
| 480 | + // double-binding them. | |
| 481 | + new self(); | |
| 482 | + } | |
| 483 | + | |
| 484 | + /** | |
| 432 | 485 | * Set up cache invalidation hooks |
| 433 | 486 | * |
| 434 | 487 | * @since 1.0.0 |
| 435 | 488 | * @return void |
| @@ -434,8 +487,23 @@ | ||
| 434 | 487 | * @since 1.0.0 |
| 435 | 488 | * @return void |
| 436 | 489 | */ |
| 437 | 490 | private function setup_cache_invalidation(): void { |
| 491 | + // The endpoint is constructed more than once per request — once on | |
| 492 | + // init via boot_cache_invalidation(), again on rest_api_init, and | |
| 493 | + // potentially by callers resolving it on demand. Bind once per | |
| 494 | + // request, or every event invalidates N times. | |
| 495 | + // | |
| 496 | + // A has_action() check cannot do this: the callback is [$this, ...] | |
| 497 | + // and each construction is a different instance, so it never matches. | |
| 498 | + static $bound = false; | |
| 499 | + | |
| 500 | + if ($bound) { | |
| 501 | + return; | |
| 502 | + } | |
| 503 | + | |
| 504 | + $bound = true; | |
| 505 | + | |
| 438 | 506 | // Invalidate analytics cache when AI usage is logged |
| 439 | 507 | add_action('thinkrank_ai_usage_logged', [$this, 'invalidate_analytics_cache']); |
| 440 | 508 | |
| 441 | 509 | // Invalidate analytics cache when SEO scores are updated |
| @@ -527,46 +595,50 @@ | ||
| 527 | 595 | ]; |
| 528 | 596 | |
| 529 | 597 | foreach ($usage_data as $usage) { |
| 530 | 598 | $tokens = (int) $usage['tokens_used']; |
| 531 | - $provider = $usage['provider']; | |
| 599 | + $provider = (string) $usage['provider']; | |
| 532 | 600 | |
| 533 | - // Estimate 70% input, 30% output tokens | |
| 534 | - $input_tokens = $tokens * 0.7; | |
| 535 | - $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 | + } | |
| 536 | 606 | |
| 537 | - $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); | |
| 538 | 615 | |
| 539 | - // Use the robust pricing helper for consistent cost calculation | |
| 540 | - $pricing = $this->get_model_pricing($provider); | |
| 541 | - if ($pricing) { | |
| 542 | - $cost = ($input_tokens * $pricing['input'] / 1000000) + | |
| 543 | - ($output_tokens * $pricing['output'] / 1000000); | |
| 544 | - $costs[$provider] += $cost; | |
| 545 | - } | |
| 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); | |
| 546 | 619 | } |
| 547 | 620 | |
| 548 | 621 | $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter']; |
| 549 | 622 | |
| 550 | - // Format provider breakdown | |
| 551 | - $costs['by_provider'] = [ | |
| 552 | - 'openai' => [ | |
| 553 | - 'cost' => round($costs['openai'], 4), | |
| 554 | - 'percentage' => $costs['total'] > 0 ? round(($costs['openai'] / $costs['total']) * 100, 1) : 0 | |
| 555 | - ], | |
| 556 | - 'claude' => [ | |
| 557 | - 'cost' => round($costs['claude'], 4), | |
| 558 | - 'percentage' => $costs['total'] > 0 ? round(($costs['claude'] / $costs['total']) * 100, 1) : 0 | |
| 559 | - ], | |
| 560 | - 'gemini' => [ | |
| 561 | - 'cost' => round($costs['gemini'], 4), | |
| 562 | - 'percentage' => $costs['total'] > 0 ? round(($costs['gemini'] / $costs['total']) * 100, 1) : 0 | |
| 563 | - ], | |
| 564 | - 'openrouter' => [ | |
| 565 | - 'cost' => round($costs['openrouter'], 4), | |
| 566 | - 'percentage' => $costs['total'] > 0 ? round(($costs['openrouter'] / $costs['total']) * 100, 1) : 0 | |
| 567 | - ] | |
| 568 | - ]; | |
| 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 | + } | |
| 569 | 641 | |
| 570 | 642 | return $costs; |
| 571 | 643 | } |
| 572 | 644 | |
| @@ -606,9 +678,9 @@ | ||
| 606 | 678 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 607 | 679 | $usage_data = $wpdb->get_results( |
| 608 | 680 | $wpdb->prepare( |
| 609 | 681 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql(). |
| 610 | - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s", | |
| 682 | + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s", | |
| 611 | 683 | $user_id, |
| 612 | 684 | $cutoff |
| 613 | 685 | ), |
| 614 | 686 | ARRAY_A |
| @@ -617,9 +689,9 @@ | ||
| 617 | 689 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 618 | 690 | $usage_data = $wpdb->get_results( |
| 619 | 691 | $wpdb->prepare( |
| 620 | 692 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql(). |
| 621 | - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d", | |
| 693 | + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d", | |
| 622 | 694 | $user_id |
| 623 | 695 | ), |
| 624 | 696 | ARRAY_A |
| 625 | 697 | ); |
| @@ -630,8 +702,16 @@ | ||
| 630 | 702 | // get_overview_metrics() reads every key unconditionally, so a |
| 631 | 703 | // short array here surfaces as undefined-key warnings and null |
| 632 | 704 | // fields for any user with no AI usage yet (i.e. a fresh install). |
| 633 | 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 | + | |
| 634 | 714 | return [ |
| 635 | 715 | 'total_actions' => 0, |
| 636 | 716 | 'total_tokens' => 0, |
| 637 | 717 | 'feature_breakdown' => [], |
| @@ -637,12 +717,17 @@ | ||
| 637 | 717 | 'feature_breakdown' => [], |
| 638 | 718 | 'features_used_count' => 0, |
| 639 | 719 | 'most_used_feature' => '', |
| 640 | 720 | 'most_used_count' => 0, |
| 641 | - 'success_rate' => 0, | |
| 642 | 721 | 'usage_data' => [], |
| 643 | - 'cost_change' => 0, | |
| 644 | - '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 | + ) | |
| 645 | 730 | ]; |
| 646 | 731 | } |
| 647 | 732 | |
| 648 | 733 | // Calculate feature breakdown and new metrics |
| @@ -674,20 +759,24 @@ | ||
| 674 | 759 | $most_used_count = $count; |
| 675 | 760 | } |
| 676 | 761 | } |
| 677 | 762 | |
| 678 | - // Calculate success rate (assuming all logged actions are successful for now) | |
| 679 | - // In future, we could track failed attempts separately | |
| 680 | - $success_rate = $total_actions > 0 ? 100 : 0; | |
| 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. | |
| 681 | 768 | |
| 682 | 769 | // Calculate changes from previous period |
| 683 | 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%. | |
| 684 | 773 | $cost_change = $this->calculate_percentage_change( |
| 685 | - $previous_period_data['total_cost'] ?? 0, | |
| 774 | + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0, | |
| 686 | 775 | $this->calculate_total_cost($usage_data) |
| 687 | 776 | ); |
| 688 | 777 | $time_saved_change = $this->calculate_percentage_change( |
| 689 | - $previous_period_data['time_saved'] ?? 0, | |
| 778 | + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0, | |
| 690 | 779 | $this->calculate_time_saved($feature_breakdown) |
| 691 | 780 | ); |
| 692 | 781 | |
| 693 | 782 | return [ |
| @@ -696,9 +785,8 @@ | ||
| 696 | 785 | 'feature_breakdown' => $feature_breakdown, |
| 697 | 786 | 'features_used_count' => $features_used_count, |
| 698 | 787 | 'most_used_feature' => $most_used_feature, |
| 699 | 788 | 'most_used_count' => $most_used_count, |
| 700 | - 'success_rate' => $success_rate, | |
| 701 | 789 | 'usage_data' => $usage_data, |
| 702 | 790 | 'cost_change' => $cost_change, |
| 703 | 791 | 'time_saved_change' => $time_saved_change |
| 704 | 792 | ]; |
| @@ -740,24 +828,38 @@ | ||
| 740 | 828 | ); |
| 741 | 829 | } |
| 742 | 830 | |
| 743 | 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 | + | |
| 744 | 839 | return [ |
| 745 | 840 | 'content_optimized' => 0, |
| 746 | 841 | 'average_seo_score' => 0, |
| 747 | - 'content_optimized_change' => 0, | |
| 748 | - '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 | + ) | |
| 749 | 850 | ]; |
| 750 | 851 | } |
| 751 | 852 | |
| 752 | 853 | // Calculate changes from previous period |
| 753 | 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. | |
| 754 | 856 | $content_optimized_change = $this->calculate_percentage_change( |
| 755 | - $previous_seo_data['content_optimized'] ?? 0, | |
| 857 | + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0, | |
| 756 | 858 | (int) $result['content_optimized'] |
| 757 | 859 | ); |
| 758 | 860 | $seo_score_change = $this->calculate_percentage_change( |
| 759 | - $previous_seo_data['average_seo_score'] ?? 0, | |
| 861 | + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0, | |
| 760 | 862 | round((float) $result['average_score'], 1) |
| 761 | 863 | ); |
| 762 | 864 | |
| 763 | 865 | return [ |
| @@ -814,9 +916,14 @@ | ||
| 814 | 916 | * @return WP_REST_Response|WP_Error Response object |
| 815 | 917 | */ |
| 816 | 918 | public function get_usage_breakdown(WP_REST_Request $request) { |
| 817 | 919 | try { |
| 818 | - $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(); | |
| 819 | 926 | $period = $request->get_param('period') ?? '30d'; |
| 820 | 927 | // `(int)` binds tighter than `??`, so `(int) null` is 0 and the |
| 821 | 928 | // `?? 20` fallback was unreachable — per_page silently defaulted to |
| 822 | 929 | // the max(10, 0) floor of 10 rather than the 20 it advertises, and |
| @@ -839,9 +946,10 @@ | ||
| 839 | 946 | 'pagination' => [ |
| 840 | 947 | 'page' => $page, |
| 841 | 948 | 'per_page' => $per_page, |
| 842 | 949 | 'total_records' => $total_records, |
| 843 | - '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 | |
| 844 | 952 | ], |
| 845 | 953 | 'period' => $period |
| 846 | 954 | ] |
| 847 | 955 | ], 200); |
| @@ -889,14 +997,22 @@ | ||
| 889 | 997 | * @param float $old_value Previous period value |
| 890 | 998 | * @param float $new_value Current period value |
| 891 | 999 | * @return float Percentage change |
| 892 | 1000 | */ |
| 893 | - 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 | + | |
| 894 | 1007 | if ((float) $old_value === 0.0) { |
| 895 | - 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; | |
| 896 | 1012 | } |
| 897 | 1013 | |
| 898 | - return round((($new_value - $old_value) / $old_value) * 100, 1); | |
| 1014 | + return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1); | |
| 899 | 1015 | } |
| 900 | 1016 | |
| 901 | 1017 | /** |
| 902 | 1018 | * Get previous period data for comparison |
| @@ -913,8 +1029,14 @@ | ||
| 913 | 1029 | |
| 914 | 1030 | // Extract the interval from current date condition to calculate previous period |
| 915 | 1031 | $previous_date_condition = $this->get_previous_period_condition($current_date_condition); |
| 916 | 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 | + | |
| 917 | 1039 | // Prepare and execute query with proper parameter binding to prevent SQL injection |
| 918 | 1040 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source |
| 919 | 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 |
| 920 | 1042 | $usage_data = $wpdb->get_results( |
| @@ -921,9 +1043,10 @@ | ||
| 921 | 1043 | $wpdb->prepare(" |
| 922 | 1044 | SELECT |
| 923 | 1045 | provider, |
| 924 | 1046 | action, |
| 925 | - tokens_used | |
| 1047 | + tokens_used, | |
| 1048 | + metadata | |
| 926 | 1049 | FROM `{$table_name}` |
| 927 | 1050 | WHERE user_id = %d |
| 928 | 1051 | {$previous_date_condition} |
| 929 | 1052 | ", $user_id), |
| @@ -1091,9 +1214,9 @@ | ||
| 1091 | 1214 | // Try specific model first, fallback to default |
| 1092 | 1215 | if ($model && isset(self::GEMINI_PRICING[$model])) { |
| 1093 | 1216 | return self::GEMINI_PRICING[$model]; |
| 1094 | 1217 | } |
| 1095 | - return self::GEMINI_PRICING['gemini-2.5-flash'] ?? null; | |
| 1218 | + return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null; | |
| 1096 | 1219 | |
| 1097 | 1220 | case 'openrouter': |
| 1098 | 1221 | // Try specific model first, fallback to default |
| 1099 | 1222 | if ($model && isset(self::OPENROUTER_PRICING[$model])) { |
| @@ -1141,8 +1264,13 @@ | ||
| 1141 | 1264 | $table_name = esc_sql($this->database->get_table('seo_scores')); |
| 1142 | 1265 | |
| 1143 | 1266 | $previous_date_condition = $this->get_previous_period_condition($current_date_condition); |
| 1144 | 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 | + | |
| 1145 | 1273 | // Prepare and execute query with proper parameter binding to prevent SQL injection |
| 1146 | 1274 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source |
| 1147 | 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 |
| 1148 | 1276 | $result = $wpdb->get_row( |
| @@ -1189,12 +1317,18 @@ | ||
| 1189 | 1317 | |
| 1190 | 1318 | /** |
| 1191 | 1319 | * Convert current period condition to previous period condition |
| 1192 | 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 | + * | |
| 1193 | 1327 | * @param string $current_condition Current period SQL condition |
| 1194 | - * @return string Previous period SQL condition | |
| 1328 | + * @return string|null Previous period SQL condition, or null when none exists | |
| 1195 | 1329 | */ |
| 1196 | - private function get_previous_period_condition(string $current_condition): string { | |
| 1330 | + private function get_previous_period_condition(string $current_condition): ?string { | |
| 1197 | 1331 | // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)" |
| 1198 | 1332 | if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) { |
| 1199 | 1333 | $interval = (int) $matches[1]; |
| 1200 | 1334 | $unit = $matches[2]; |
| @@ -1206,9 +1340,9 @@ | ||
| 1206 | 1340 | return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit}) |
| 1207 | 1341 | AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})"; |
| 1208 | 1342 | } |
| 1209 | 1343 | |
| 1210 | - // Fallback for unknown conditions | |
| 1211 | - return "AND created_at >= DATE_SUB(NOW(), INTERVAL 60 DAY) | |
| 1212 | - AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"; | |
| 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; | |
| 1213 | 1347 | } |
| 1214 | 1348 | } |