PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-usage-analytics-endpoint.php +358 -110 1.10.02.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 }