← All changes
|
includes/api/class-usage-analytics-endpoint.php
+358
-110
1.10.0
→
2.7.0
View file →
| @@ -93,12 +93,29 @@ | ||
| 93 | 93 | * Claude pricing per 1M tokens (USD) |
| 94 | 94 | * Model IDs sourced from https://docs.anthropic.com/en/docs/about-claude/models |
| 95 | 95 | */ |
| 96 | 96 | private const CLAUDE_PRICING = [ |
| 97 | + // Current models (recommended) | |
| 98 | + 'claude-opus-5' => [ | |
| 99 | + 'input' => 5.00, | |
| 100 | + 'output' => 25.00 | |
| 101 | + ], | |
| 102 | + 'claude-opus-4-8' => [ | |
| 103 | + 'input' => 5.00, | |
| 104 | + 'output' => 25.00 | |
| 105 | + ], | |
| 106 | + 'claude-sonnet-5' => [ | |
| 107 | + 'input' => 3.00, | |
| 108 | + 'output' => 15.00 | |
| 109 | + ], | |
| 110 | + 'claude-haiku-4-5' => [ | |
| 111 | + 'input' => 1.00, | |
| 112 | + 'output' => 5.00 | |
| 113 | + ], | |
| 97 | 114 | // Claude 4.x models |
| 98 | 115 | 'claude-opus-4-6' => [ |
| 99 | - 'input' => 15.00, | |
| 100 | - 'output' => 75.00 | |
| 116 | + 'input' => 5.00, | |
| 117 | + 'output' => 25.00 | |
| 101 | 118 | ], |
| 102 | 119 | 'claude-sonnet-4-6' => [ |
| 103 | 120 | 'input' => 3.00, |
| 104 | 121 | 'output' => 15.00 |
| @@ -104,10 +121,10 @@ | ||
| 104 | 121 | 'output' => 15.00 |
| 105 | 122 | ], |
| 106 | 123 | // Claude 4.5 models |
| 107 | 124 | 'claude-haiku-4-5-20251001' => [ |
| 108 | - 'input' => 0.80, | |
| 109 | - 'output' => 4.00 | |
| 125 | + 'input' => 1.00, | |
| 126 | + 'output' => 5.00 | |
| 110 | 127 | ], |
| 111 | 128 | // Claude 3.5 models (legacy) |
| 112 | 129 | 'claude-3-5-sonnet-20241022' => [ |
| 113 | 130 | 'input' => 3.00, |
| @@ -126,8 +143,26 @@ | ||
| 126 | 143 | /** |
| 127 | 144 | * Gemini pricing per 1M tokens (USD) |
| 128 | 145 | */ |
| 129 | 146 | private const GEMINI_PRICING = [ |
| 147 | + // Gemini 3.x models (tiered models use the base <=200k-token rate) | |
| 148 | + 'gemini-3.1-pro' => [ | |
| 149 | + 'input' => 2.00, | |
| 150 | + 'output' => 12.00 | |
| 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 | + ], | |
| 157 | + 'gemini-3.5-flash' => [ | |
| 158 | + 'input' => 1.50, | |
| 159 | + 'output' => 9.00 | |
| 160 | + ], | |
| 161 | + 'gemini-3.1-flash-lite' => [ | |
| 162 | + 'input' => 0.25, | |
| 163 | + 'output' => 1.50 | |
| 164 | + ], | |
| 130 | 165 | // Gemini 2.5 models |
| 131 | 166 | 'gemini-2.5-flash' => [ |
| 132 | 167 | 'input' => 0.30, |
| 133 | 168 | 'output' => 2.50 |
| @@ -136,15 +171,15 @@ | ||
| 136 | 171 | 'input' => 0.10, |
| 137 | 172 | 'output' => 0.40 |
| 138 | 173 | ], |
| 139 | 174 | 'gemini-2.5-pro' => [ |
| 140 | - 'input' => 3.50, | |
| 141 | - 'output' => 10.50 | |
| 175 | + 'input' => 1.25, | |
| 176 | + 'output' => 10.00 | |
| 142 | 177 | ], |
| 143 | 178 | // Gemini 2.0 models |
| 144 | 179 | 'gemini-2.0-flash' => [ |
| 145 | - 'input' => 0.075, | |
| 146 | - 'output' => 0.30 | |
| 180 | + 'input' => 0.10, | |
| 181 | + 'output' => 0.40 | |
| 147 | 182 | ], |
| 148 | 183 | // Gemini 1.5 models |
| 149 | 184 | 'gemini-1.5-flash' => [ |
| 150 | 185 | 'input' => 0.075, |
| @@ -154,10 +189,48 @@ | ||
| 154 | 189 | 'input' => 1.25, |
| 155 | 190 | 'output' => 5.00 |
| 156 | 191 | ] |
| 157 | 192 | ]; |
| 158 | - | |
| 193 | + | |
| 159 | 194 | /** |
| 195 | + * OpenRouter pricing per 1M tokens (USD) | |
| 196 | + * | |
| 197 | + * OpenRouter passes through each upstream model's pricing; these are | |
| 198 | + * representative rates for the curated model list used for cost estimates. | |
| 199 | + */ | |
| 200 | + private const OPENROUTER_PRICING = [ | |
| 201 | + 'openai/gpt-4o-mini' => [ | |
| 202 | + 'input' => 0.15, | |
| 203 | + 'output' => 0.60 | |
| 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. | |
| 214 | + 'anthropic/claude-3.5-sonnet' => [ | |
| 215 | + 'input' => 3.00, | |
| 216 | + 'output' => 15.00 | |
| 217 | + ], | |
| 218 | + 'google/gemini-2.0-flash-001' => [ | |
| 219 | + 'input' => 0.10, | |
| 220 | + 'output' => 0.40 | |
| 221 | + ], | |
| 222 | + 'meta-llama/llama-3.3-70b-instruct' => [ | |
| 223 | + 'input' => 0.12, | |
| 224 | + 'output' => 0.30 | |
| 225 | + ], | |
| 226 | + 'deepseek/deepseek-chat' => [ | |
| 227 | + 'input' => 0.14, | |
| 228 | + 'output' => 0.28 | |
| 229 | + ] | |
| 230 | + ]; | |
| 231 | + | |
| 232 | + /** | |
| 160 | 233 | * Time saved estimates per action (minutes) |
| 161 | 234 | */ |
| 162 | 235 | private const TIME_SAVED_ESTIMATES = [ |
| 163 | 236 | 'seo_metadata' => 20, |
| @@ -193,8 +266,9 @@ | ||
| 193 | 266 | 'permission_callback' => [$this, 'check_permissions'], |
| 194 | 267 | 'args' => [ |
| 195 | 268 | 'period' => [ |
| 196 | 269 | 'default' => '30d', |
| 270 | + 'type' => 'string', | |
| 197 | 271 | 'enum' => ['7d', '30d', '90d', 'all'], |
| 198 | 272 | 'sanitize_callback' => 'sanitize_key' |
| 199 | 273 | ], |
| 200 | 274 | 'user_id' => [ |
| @@ -212,20 +286,32 @@ | ||
| 212 | 286 | 'permission_callback' => [$this, 'check_permissions'], |
| 213 | 287 | 'args' => [ |
| 214 | 288 | 'period' => [ |
| 215 | 289 | 'default' => '30d', |
| 290 | + 'type' => 'string', | |
| 216 | 291 | 'enum' => ['7d', '30d', '90d', 'all'], |
| 217 | 292 | 'sanitize_callback' => 'sanitize_key' |
| 218 | 293 | ], |
| 219 | - 'group_by' => [ | |
| 220 | - 'default' => 'day', | |
| 221 | - 'enum' => ['day', 'week', 'month'], | |
| 222 | - 'sanitize_callback' => 'sanitize_key' | |
| 223 | - ], | |
| 224 | 294 | 'user_id' => [ |
| 225 | 295 | 'default' => 0, |
| 226 | 296 | 'type' => 'integer', |
| 227 | 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' | |
| 228 | 314 | ] |
| 229 | 315 | ] |
| 230 | 316 | ]); |
| 231 | 317 | |
| @@ -236,14 +322,16 @@ | ||
| 236 | 322 | 'permission_callback' => [$this, 'check_permissions'], |
| 237 | 323 | 'args' => [ |
| 238 | 324 | 'period' => [ |
| 239 | 325 | 'default' => '30d', |
| 326 | + 'type' => 'string', | |
| 240 | 327 | 'enum' => ['7d', '30d', '90d', 'all'], |
| 241 | 328 | 'sanitize_callback' => 'sanitize_key' |
| 242 | 329 | ], |
| 243 | 330 | 'provider' => [ |
| 244 | 331 | 'default' => 'all', |
| 245 | - 'enum' => ['all', 'openai', 'claude'], | |
| 332 | + 'type' => 'string', | |
| 333 | + 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter'], | |
| 246 | 334 | 'sanitize_callback' => 'sanitize_key' |
| 247 | 335 | ], |
| 248 | 336 | 'user_id' => [ |
| 249 | 337 | 'default' => 0, |
| @@ -259,9 +347,9 @@ | ||
| 259 | 347 | * |
| 260 | 348 | * @param WP_REST_Request $request Request object |
| 261 | 349 | * @return WP_REST_Response|WP_Error Response object |
| 262 | 350 | */ |
| 263 | - public function get_overview_metrics(WP_REST_Request $request): WP_REST_Response|WP_Error { | |
| 351 | + public function get_overview_metrics(WP_REST_Request $request) { | |
| 264 | 352 | $period = $request->get_param('period'); |
| 265 | 353 | $user_id = $request->get_param('user_id') ?: get_current_user_id(); |
| 266 | 354 | |
| 267 | 355 | try { |
| @@ -302,9 +390,8 @@ | ||
| 302 | 390 | 'ai_actions' => $ai_metrics['total_actions'], |
| 303 | 391 | 'features_used_count' => $ai_metrics['features_used_count'], |
| 304 | 392 | 'most_used_feature' => $ai_metrics['most_used_feature'], |
| 305 | 393 | 'most_used_count' => $ai_metrics['most_used_count'], |
| 306 | - 'success_rate' => $ai_metrics['success_rate'], | |
| 307 | 394 | 'content_briefs' => $brief_metrics['total_briefs'], |
| 308 | 395 | 'feature_breakdown' => $ai_metrics['feature_breakdown'], |
| 309 | 396 | 'provider_breakdown' => $cost_data['by_provider'] |
| 310 | 397 | ], |
| @@ -333,9 +420,9 @@ | ||
| 333 | 420 | * |
| 334 | 421 | * @param WP_REST_Request $request Request object |
| 335 | 422 | * @return bool|WP_Error Permission result |
| 336 | 423 | */ |
| 337 | - public function check_permissions(WP_REST_Request $request): bool|WP_Error { | |
| 424 | + public function check_permissions(WP_REST_Request $request) { | |
| 338 | 425 | // Check if user is logged in |
| 339 | 426 | if (!is_user_logged_in()) { |
| 340 | 427 | return new WP_Error( |
| 341 | 428 | 'not_logged_in', |
| @@ -368,8 +455,34 @@ | ||
| 368 | 455 | return true; |
| 369 | 456 | } |
| 370 | 457 | |
| 371 | 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 | + /** | |
| 372 | 485 | * Set up cache invalidation hooks |
| 373 | 486 | * |
| 374 | 487 | * @since 1.0.0 |
| 375 | 488 | * @return void |
| @@ -374,8 +487,23 @@ | ||
| 374 | 487 | * @since 1.0.0 |
| 375 | 488 | * @return void |
| 376 | 489 | */ |
| 377 | 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 | + | |
| 378 | 506 | // Invalidate analytics cache when AI usage is logged |
| 379 | 507 | add_action('thinkrank_ai_usage_logged', [$this, 'invalidate_analytics_cache']); |
| 380 | 508 | |
| 381 | 509 | // Invalidate analytics cache when SEO scores are updated |
| @@ -403,15 +531,24 @@ | ||
| 403 | 531 | * @param string $period Period string |
| 404 | 532 | * @return string|null Cutoff datetime in MySQL format, or null for all time |
| 405 | 533 | */ |
| 406 | 534 | private function get_date_cutoff(string $period): ?string { |
| 407 | - $days = match($period) { | |
| 408 | - '7d' => 7, | |
| 409 | - '30d' => 30, | |
| 410 | - '90d' => 90, | |
| 411 | - 'all' => null, | |
| 412 | - default => 30, | |
| 413 | - }; | |
| 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 | + } | |
| 414 | 551 | if ($days === null) { |
| 415 | 552 | return null; |
| 416 | 553 | } |
| 417 | 554 | return gmdate('Y-m-d H:i:s', strtotime("-{$days} days")); |
| @@ -426,15 +563,20 @@ | ||
| 426 | 563 | * @param string $period Period string |
| 427 | 564 | * @return string SQL date condition |
| 428 | 565 | */ |
| 429 | 566 | private function get_date_condition(string $period): string { |
| 430 | - return match($period) { | |
| 431 | - '7d' => "AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)", | |
| 432 | - '30d' => "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)", | |
| 433 | - '90d' => "AND created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY)", | |
| 434 | - 'all' => "", | |
| 435 | - default => "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)" | |
| 436 | - }; | |
| 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 | + } | |
| 437 | 579 | } |
| 438 | 580 | |
| 439 | 581 | /** |
| 440 | 582 | * Calculate costs from usage data |
| @@ -446,8 +588,9 @@ | ||
| 446 | 588 | $costs = [ |
| 447 | 589 | 'openai' => 0, |
| 448 | 590 | 'claude' => 0, |
| 449 | 591 | 'gemini' => 0, |
| 592 | + 'openrouter' => 0, | |
| 450 | 593 | 'total' => 0, |
| 451 | 594 | 'by_provider' => [] |
| 452 | 595 | ]; |
| 453 | 596 | |
| @@ -452,43 +595,51 @@ | ||
| 452 | 595 | ]; |
| 453 | 596 | |
| 454 | 597 | foreach ($usage_data as $usage) { |
| 455 | 598 | $tokens = (int) $usage['tokens_used']; |
| 456 | - $provider = $usage['provider']; | |
| 599 | + $provider = (string) $usage['provider']; | |
| 457 | 600 | |
| 458 | - // Estimate 70% input, 30% output tokens | |
| 459 | - $input_tokens = $tokens * 0.7; | |
| 460 | - $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 | + } | |
| 461 | 606 | |
| 462 | - $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); | |
| 463 | 615 | |
| 464 | - // Use the robust pricing helper for consistent cost calculation | |
| 465 | - $pricing = $this->get_model_pricing($provider); | |
| 466 | - if ($pricing) { | |
| 467 | - $cost = ($input_tokens * $pricing['input'] / 1000000) + | |
| 468 | - ($output_tokens * $pricing['output'] / 1000000); | |
| 469 | - $costs[$provider] += $cost; | |
| 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); | |
| 619 | + } | |
| 620 | + | |
| 621 | + $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter']; | |
| 622 | + | |
| 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; | |
| 470 | 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 | + ]; | |
| 471 | 640 | } |
| 472 | 641 | |
| 473 | - $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini']; | |
| 474 | - | |
| 475 | - // Format provider breakdown | |
| 476 | - $costs['by_provider'] = [ | |
| 477 | - 'openai' => [ | |
| 478 | - 'cost' => round($costs['openai'], 4), | |
| 479 | - 'percentage' => $costs['total'] > 0 ? round(($costs['openai'] / $costs['total']) * 100, 1) : 0 | |
| 480 | - ], | |
| 481 | - 'claude' => [ | |
| 482 | - 'cost' => round($costs['claude'], 4), | |
| 483 | - 'percentage' => $costs['total'] > 0 ? round(($costs['claude'] / $costs['total']) * 100, 1) : 0 | |
| 484 | - ], | |
| 485 | - 'gemini' => [ | |
| 486 | - 'cost' => round($costs['gemini'], 4), | |
| 487 | - 'percentage' => $costs['total'] > 0 ? round(($costs['gemini'] / $costs['total']) * 100, 1) : 0 | |
| 488 | - ] | |
| 489 | - ]; | |
| 490 | - | |
| 491 | 642 | return $costs; |
| 492 | 643 | } |
| 493 | 644 | |
| 494 | 645 | /** |
| @@ -526,9 +677,10 @@ | ||
| 526 | 677 | if ($cutoff !== null) { |
| 527 | 678 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 528 | 679 | $usage_data = $wpdb->get_results( |
| 529 | 680 | $wpdb->prepare( |
| 530 | - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s", | |
| 681 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql(). | |
| 682 | + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s", | |
| 531 | 683 | $user_id, |
| 532 | 684 | $cutoff |
| 533 | 685 | ), |
| 534 | 686 | ARRAY_A |
| @@ -536,9 +688,10 @@ | ||
| 536 | 688 | } else { |
| 537 | 689 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 538 | 690 | $usage_data = $wpdb->get_results( |
| 539 | 691 | $wpdb->prepare( |
| 540 | - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d", | |
| 692 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql(). | |
| 693 | + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d", | |
| 541 | 694 | $user_id |
| 542 | 695 | ), |
| 543 | 696 | ARRAY_A |
| 544 | 697 | ); |
| @@ -544,15 +697,37 @@ | ||
| 544 | 697 | ); |
| 545 | 698 | } |
| 546 | 699 | |
| 547 | 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 | + | |
| 548 | 714 | return [ |
| 549 | 715 | 'total_actions' => 0, |
| 550 | 716 | 'total_tokens' => 0, |
| 551 | 717 | 'feature_breakdown' => [], |
| 718 | + 'features_used_count' => 0, | |
| 719 | + 'most_used_feature' => '', | |
| 720 | + 'most_used_count' => 0, | |
| 552 | 721 | 'usage_data' => [], |
| 553 | - 'cost_change' => 0, | |
| 554 | - '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 | + ) | |
| 555 | 730 | ]; |
| 556 | 731 | } |
| 557 | 732 | |
| 558 | 733 | // Calculate feature breakdown and new metrics |
| @@ -584,20 +759,24 @@ | ||
| 584 | 759 | $most_used_count = $count; |
| 585 | 760 | } |
| 586 | 761 | } |
| 587 | 762 | |
| 588 | - // Calculate success rate (assuming all logged actions are successful for now) | |
| 589 | - // In future, we could track failed attempts separately | |
| 590 | - $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. | |
| 591 | 768 | |
| 592 | 769 | // Calculate changes from previous period |
| 593 | 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%. | |
| 594 | 773 | $cost_change = $this->calculate_percentage_change( |
| 595 | - $previous_period_data['total_cost'] ?? 0, | |
| 774 | + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0, | |
| 596 | 775 | $this->calculate_total_cost($usage_data) |
| 597 | 776 | ); |
| 598 | 777 | $time_saved_change = $this->calculate_percentage_change( |
| 599 | - $previous_period_data['time_saved'] ?? 0, | |
| 778 | + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0, | |
| 600 | 779 | $this->calculate_time_saved($feature_breakdown) |
| 601 | 780 | ); |
| 602 | 781 | |
| 603 | 782 | return [ |
| @@ -606,9 +785,8 @@ | ||
| 606 | 785 | 'feature_breakdown' => $feature_breakdown, |
| 607 | 786 | 'features_used_count' => $features_used_count, |
| 608 | 787 | 'most_used_feature' => $most_used_feature, |
| 609 | 788 | 'most_used_count' => $most_used_count, |
| 610 | - 'success_rate' => $success_rate, | |
| 611 | 789 | 'usage_data' => $usage_data, |
| 612 | 790 | 'cost_change' => $cost_change, |
| 613 | 791 | 'time_saved_change' => $time_saved_change |
| 614 | 792 | ]; |
| @@ -630,8 +808,9 @@ | ||
| 630 | 808 | if ($cutoff !== null) { |
| 631 | 809 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 632 | 810 | $result = $wpdb->get_row( |
| 633 | 811 | $wpdb->prepare( |
| 812 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql(). | |
| 634 | 813 | "SELECT COUNT(DISTINCT post_id) as content_optimized, AVG(overall_score) as average_score FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s", |
| 635 | 814 | $user_id, |
| 636 | 815 | $cutoff |
| 637 | 816 | ), |
| @@ -640,8 +819,9 @@ | ||
| 640 | 819 | } else { |
| 641 | 820 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 642 | 821 | $result = $wpdb->get_row( |
| 643 | 822 | $wpdb->prepare( |
| 823 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql(). | |
| 644 | 824 | "SELECT COUNT(DISTINCT post_id) as content_optimized, AVG(overall_score) as average_score FROM `{$table_name}` WHERE user_id = %d", |
| 645 | 825 | $user_id |
| 646 | 826 | ), |
| 647 | 827 | ARRAY_A |
| @@ -647,25 +827,39 @@ | ||
| 647 | 827 | ARRAY_A |
| 648 | 828 | ); |
| 649 | 829 | } |
| 650 | 830 | |
| 651 | - 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 | + | |
| 652 | 839 | return [ |
| 653 | 840 | 'content_optimized' => 0, |
| 654 | 841 | 'average_seo_score' => 0, |
| 655 | - 'content_optimized_change' => 0, | |
| 656 | - '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 | + ) | |
| 657 | 850 | ]; |
| 658 | 851 | } |
| 659 | 852 | |
| 660 | 853 | // Calculate changes from previous period |
| 661 | 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. | |
| 662 | 856 | $content_optimized_change = $this->calculate_percentage_change( |
| 663 | - $previous_seo_data['content_optimized'] ?? 0, | |
| 857 | + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0, | |
| 664 | 858 | (int) $result['content_optimized'] |
| 665 | 859 | ); |
| 666 | 860 | $seo_score_change = $this->calculate_percentage_change( |
| 667 | - $previous_seo_data['average_seo_score'] ?? 0, | |
| 861 | + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0, | |
| 668 | 862 | round((float) $result['average_score'], 1) |
| 669 | 863 | ); |
| 670 | 864 | |
| 671 | 865 | return [ |
| @@ -692,8 +886,9 @@ | ||
| 692 | 886 | if ($cutoff !== null) { |
| 693 | 887 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 694 | 888 | $result = $wpdb->get_var( |
| 695 | 889 | $wpdb->prepare( |
| 890 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql(). | |
| 696 | 891 | "SELECT COUNT(*) as total_briefs FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s", |
| 697 | 892 | $user_id, |
| 698 | 893 | $cutoff |
| 699 | 894 | ) |
| @@ -701,8 +896,9 @@ | ||
| 701 | 896 | } else { |
| 702 | 897 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 703 | 898 | $result = $wpdb->get_var( |
| 704 | 899 | $wpdb->prepare( |
| 900 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql(). | |
| 705 | 901 | "SELECT COUNT(*) as total_briefs FROM `{$table_name}` WHERE user_id = %d", |
| 706 | 902 | $user_id |
| 707 | 903 | ) |
| 708 | 904 | ); |
| @@ -718,14 +914,23 @@ | ||
| 718 | 914 | * |
| 719 | 915 | * @param WP_REST_Request $request Request object |
| 720 | 916 | * @return WP_REST_Response|WP_Error Response object |
| 721 | 917 | */ |
| 722 | - public function get_usage_breakdown(WP_REST_Request $request): WP_REST_Response|WP_Error { | |
| 918 | + public function get_usage_breakdown(WP_REST_Request $request) { | |
| 723 | 919 | try { |
| 724 | - $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(); | |
| 725 | 926 | $period = $request->get_param('period') ?? '30d'; |
| 726 | - $page = max(1, (int) $request->get_param('page') ?? 1); | |
| 727 | - $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))); | |
| 728 | 933 | $offset = ($page - 1) * $per_page; |
| 729 | 934 | |
| 730 | 935 | // Get date range for queries |
| 731 | 936 | $date_condition = $this->get_date_condition($period); |
| @@ -741,9 +946,10 @@ | ||
| 741 | 946 | 'pagination' => [ |
| 742 | 947 | 'page' => $page, |
| 743 | 948 | 'per_page' => $per_page, |
| 744 | 949 | 'total_records' => $total_records, |
| 745 | - '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 | |
| 746 | 952 | ], |
| 747 | 953 | 'period' => $period |
| 748 | 954 | ] |
| 749 | 955 | ], 200); |
| @@ -762,14 +968,17 @@ | ||
| 762 | 968 | * |
| 763 | 969 | * @param WP_REST_Request $request Request object |
| 764 | 970 | * @return WP_REST_Response|WP_Error Response object |
| 765 | 971 | */ |
| 766 | - public function get_cost_analysis(WP_REST_Request $request): WP_REST_Response|WP_Error { | |
| 972 | + public function get_cost_analysis(WP_REST_Request $request) { | |
| 973 | + // Return 200 with success:false so the frontend can render an | |
| 974 | + // "unavailable" state — apiFetch rejects on non-2xx, which would | |
| 975 | + // otherwise surface as a generic hard error. | |
| 767 | 976 | return new WP_REST_Response([ |
| 768 | 977 | 'success' => false, |
| 769 | 978 | 'data' => null, |
| 770 | 979 | 'message' => 'Cost analysis is not yet implemented.' |
| 771 | - ], 501); | |
| 980 | + ], 200); | |
| 772 | 981 | } |
| 773 | 982 | |
| 774 | 983 | /** |
| 775 | 984 | * Calculate total cost from usage data |
| @@ -788,14 +997,22 @@ | ||
| 788 | 997 | * @param float $old_value Previous period value |
| 789 | 998 | * @param float $new_value Current period value |
| 790 | 999 | * @return float Percentage change |
| 791 | 1000 | */ |
| 792 | - private function calculate_percentage_change(float $old_value, float $new_value): float { | |
| 793 | - if ($old_value == 0) { | |
| 794 | - 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; | |
| 795 | 1005 | } |
| 796 | 1006 | |
| 797 | - 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); | |
| 798 | 1015 | } |
| 799 | 1016 | |
| 800 | 1017 | /** |
| 801 | 1018 | * Get previous period data for comparison |
| @@ -812,17 +1029,24 @@ | ||
| 812 | 1029 | |
| 813 | 1030 | // Extract the interval from current date condition to calculate previous period |
| 814 | 1031 | $previous_date_condition = $this->get_previous_period_condition($current_date_condition); |
| 815 | 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 | + | |
| 816 | 1039 | // Prepare and execute query with proper parameter binding to prevent SQL injection |
| 817 | 1040 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source |
| 818 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Analytics data is real-time and shouldn't be cached | |
| 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 | |
| 819 | 1042 | $usage_data = $wpdb->get_results( |
| 820 | 1043 | $wpdb->prepare(" |
| 821 | 1044 | SELECT |
| 822 | 1045 | provider, |
| 823 | 1046 | action, |
| 824 | - tokens_used | |
| 1047 | + tokens_used, | |
| 1048 | + metadata | |
| 825 | 1049 | FROM `{$table_name}` |
| 826 | 1050 | WHERE user_id = %d |
| 827 | 1051 | {$previous_date_condition} |
| 828 | 1052 | ", $user_id), |
| @@ -879,11 +1103,11 @@ | ||
| 879 | 1103 | ORDER BY created_at DESC |
| 880 | 1104 | LIMIT %d OFFSET %d |
| 881 | 1105 | "; |
| 882 | 1106 | |
| 883 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data is real-time, table name and date condition are validated internally | |
| 1107 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time, table name and date condition are validated internally | |
| 884 | 1108 | $usage_data = $wpdb->get_results( |
| 885 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 1109 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 886 | 1110 | $wpdb->prepare($sql, $user_id, $limit, $offset), |
| 887 | 1111 | ARRAY_A |
| 888 | 1112 | ); |
| 889 | 1113 | |
| @@ -928,11 +1152,11 @@ | ||
| 928 | 1152 | WHERE user_id = %d |
| 929 | 1153 | {$date_condition} |
| 930 | 1154 | "; |
| 931 | 1155 | |
| 932 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Analytics data is real-time, table name and date condition are validated internally | |
| 1156 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time, table name and date condition are validated internally | |
| 933 | 1157 | $count = $wpdb->get_var( |
| 934 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders | |
| 1158 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders | |
| 935 | 1159 | $wpdb->prepare($sql, $user_id) |
| 936 | 1160 | ); |
| 937 | 1161 | |
| 938 | 1162 | return (int) $count; |
| @@ -958,9 +1182,9 @@ | ||
| 958 | 1182 | $input_tokens = $tokens_used * 0.7; |
| 959 | 1183 | $output_tokens = $tokens_used * 0.3; |
| 960 | 1184 | |
| 961 | 1185 | return (($input_tokens / 1000000) * $pricing['input']) + |
| 962 | - (($output_tokens / 1000000) * $pricing['output']); | |
| 1186 | + (($output_tokens / 1000000) * $pricing['output']); | |
| 963 | 1187 | } |
| 964 | 1188 | |
| 965 | 1189 | /** |
| 966 | 1190 | * Get pricing for any model with intelligent fallbacks |
| @@ -982,10 +1206,10 @@ | ||
| 982 | 1206 | // Try specific model first, fallback to recommended default |
| 983 | 1207 | if ($model && isset(self::CLAUDE_PRICING[$model])) { |
| 984 | 1208 | return self::CLAUDE_PRICING[$model]; |
| 985 | 1209 | } |
| 986 | - return self::CLAUDE_PRICING['claude-sonnet-4-6'] ?? | |
| 987 | - self::CLAUDE_PRICING['claude-3-5-sonnet-20241022'] ?? null; | |
| 1210 | + return self::CLAUDE_PRICING['claude-sonnet-5'] ?? | |
| 1211 | + self::CLAUDE_PRICING['claude-sonnet-4-6'] ?? null; | |
| 988 | 1212 | |
| 989 | 1213 | case 'gemini': |
| 990 | 1214 | // Try specific model first, fallback to default |
| 991 | 1215 | if ($model && isset(self::GEMINI_PRICING[$model])) { |
| @@ -990,10 +1214,17 @@ | ||
| 990 | 1214 | // Try specific model first, fallback to default |
| 991 | 1215 | if ($model && isset(self::GEMINI_PRICING[$model])) { |
| 992 | 1216 | return self::GEMINI_PRICING[$model]; |
| 993 | 1217 | } |
| 994 | - return self::GEMINI_PRICING['gemini-2.5-flash'] ?? null; | |
| 1218 | + return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null; | |
| 995 | 1219 | |
| 1220 | + case 'openrouter': | |
| 1221 | + // Try specific model first, fallback to default | |
| 1222 | + if ($model && isset(self::OPENROUTER_PRICING[$model])) { | |
| 1223 | + return self::OPENROUTER_PRICING[$model]; | |
| 1224 | + } | |
| 1225 | + return self::OPENROUTER_PRICING['openai/gpt-4o-mini'] ?? null; | |
| 1226 | + | |
| 996 | 1227 | default: |
| 997 | 1228 | return null; |
| 998 | 1229 | } |
| 999 | 1230 | } |
| @@ -1006,13 +1237,15 @@ | ||
| 1006 | 1237 | */ |
| 1007 | 1238 | private function get_default_model(string $provider): string { |
| 1008 | 1239 | switch ($provider) { |
| 1009 | 1240 | case 'openai': |
| 1010 | - return 'gpt-5-nano'; | |
| 1241 | + return \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL; | |
| 1011 | 1242 | case 'claude': |
| 1012 | - return 'claude-sonnet-4-6'; | |
| 1243 | + return \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL; | |
| 1013 | 1244 | case 'gemini': |
| 1014 | - return 'gemini-2.5-flash'; // Keep stable default model | |
| 1245 | + return \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL; | |
| 1246 | + case 'openrouter': | |
| 1247 | + return \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL; | |
| 1015 | 1248 | default: |
| 1016 | 1249 | return 'unknown'; |
| 1017 | 1250 | } |
| 1018 | 1251 | } |
| @@ -1031,11 +1264,16 @@ | ||
| 1031 | 1264 | $table_name = esc_sql($this->database->get_table('seo_scores')); |
| 1032 | 1265 | |
| 1033 | 1266 | $previous_date_condition = $this->get_previous_period_condition($current_date_condition); |
| 1034 | 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 | + | |
| 1035 | 1273 | // Prepare and execute query with proper parameter binding to prevent SQL injection |
| 1036 | 1274 | // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source |
| 1037 | - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Analytics data is real-time and shouldn't be cached | |
| 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 | |
| 1038 | 1276 | $result = $wpdb->get_row( |
| 1039 | 1277 | $wpdb->prepare(" |
| 1040 | 1278 | SELECT |
| 1041 | 1279 | COUNT(DISTINCT post_id) as content_optimized, |
| @@ -1065,12 +1303,16 @@ | ||
| 1065 | 1303 | * @param string $condition Legacy date condition string |
| 1066 | 1304 | * @return string Period key |
| 1067 | 1305 | */ |
| 1068 | 1306 | private function resolve_period_from_condition(string $condition): string { |
| 1069 | - if (strpos($condition, 'INTERVAL 7') !== false) return '7d'; | |
| 1070 | - if (strpos($condition, 'INTERVAL 30') !== false) return '30d'; | |
| 1071 | - if (strpos($condition, 'INTERVAL 90') !== false) return '90d'; | |
| 1072 | - 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 | + } | |
| 1073 | 1315 | return '30d'; |
| 1074 | 1316 | } |
| 1075 | 1317 | |
| 1076 | 1318 | /** |
| @@ -1075,12 +1317,18 @@ | ||
| 1075 | 1317 | |
| 1076 | 1318 | /** |
| 1077 | 1319 | * Convert current period condition to previous period condition |
| 1078 | 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 | + * | |
| 1079 | 1327 | * @param string $current_condition Current period SQL condition |
| 1080 | - * @return string Previous period SQL condition | |
| 1328 | + * @return string|null Previous period SQL condition, or null when none exists | |
| 1081 | 1329 | */ |
| 1082 | - private function get_previous_period_condition(string $current_condition): string { | |
| 1330 | + private function get_previous_period_condition(string $current_condition): ?string { | |
| 1083 | 1331 | // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)" |
| 1084 | 1332 | if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) { |
| 1085 | 1333 | $interval = (int) $matches[1]; |
| 1086 | 1334 | $unit = $matches[2]; |
| @@ -1092,9 +1340,9 @@ | ||
| 1092 | 1340 | return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit}) |
| 1093 | 1341 | AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})"; |
| 1094 | 1342 | } |
| 1095 | 1343 | |
| 1096 | - // Fallback for unknown conditions | |
| 1097 | - return "AND created_at >= DATE_SUB(NOW(), INTERVAL 60 DAY) | |
| 1098 | - 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; | |
| 1099 | 1347 | } |
| 1100 | 1348 | } |