PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
thinkrank / includes / api / class-usage-analytics-endpoint.php

class-usage-analytics-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/api/class-usage-analytics-endpoint.php

1,370 lines 51.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Usage Analytics REST API Endpoint
4 *
5 * Handles REST API endpoints for usage analytics data including AI usage, costs, and performance metrics
6 *
7 * @package ThinkRank\API
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\API;
14
15 use ThinkRank\Core\Database;
16 use ThinkRank\API\Traits\API_Cache;
17 use WP_REST_Request;
18 use WP_REST_Response;
19 use WP_Error;
20
21 // Prevent direct access
22 if (!defined('ABSPATH')) {
23 exit;
24 }
25
26 // Load API Cache trait
27 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-api-cache.php';
28
29 /**
30 * Usage Analytics Endpoint Class
31 *
32 * Provides REST API endpoints for:
33 * - /wp-json/thinkrank/v1/analytics/overview
34 * - /wp-json/thinkrank/v1/analytics/usage
35 * - /wp-json/thinkrank/v1/analytics/costs
36 *
37 * @since 1.0.0
38 */
39 class Usage_Analytics_Endpoint {
40
41 use API_Cache;
42
43 /**
44 * Database instance
45 *
46 * @var Database
47 */
48 private Database $database;
49
50 /**
51 * OpenAI pricing per 1M tokens (USD)
52 */
53 private const OPENAI_PRICING = [
54 // GPT-5 models (official pricing from OpenAI)
55 'gpt-5' => [
56 'input' => 1.25,
57 'output' => 10.00
58 ],
59 'gpt-5-mini' => [
60 'input' => 0.25,
61 'output' => 2.00
62 ],
63 'gpt-5-nano' => [
64 'input' => 0.05,
65 'output' => 0.40
66 ],
67 // GPT-4 models
68 'gpt-4.1' => [
69 'input' => 2.50,
70 'output' => 10.00
71 ],
72 'gpt-4o' => [
73 'input' => 2.50,
74 'output' => 10.00
75 ],
76 'gpt-4-turbo' => [
77 'input' => 10.00,
78 'output' => 30.00
79 ],
80 // O3 models
81 'o3-mini' => [
82 'input' => 1.25,
83 'output' => 5.00
84 ],
85 // Mini models
86 'gpt-4o-mini' => [
87 'input' => 0.15,
88 'output' => 0.60
89 ]
90 ];
91
92 /**
93 * Claude pricing per 1M tokens (USD)
94 * Model IDs sourced from https://docs.anthropic.com/en/docs/about-claude/models
95 */
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 ],
114 // Claude 4.x models
115 'claude-opus-4-6' => [
116 'input' => 5.00,
117 'output' => 25.00
118 ],
119 'claude-sonnet-4-6' => [
120 'input' => 3.00,
121 'output' => 15.00
122 ],
123 // Claude 4.5 models
124 'claude-haiku-4-5-20251001' => [
125 'input' => 1.00,
126 'output' => 5.00
127 ],
128 // Claude 3.5 models (legacy)
129 'claude-3-5-sonnet-20241022' => [
130 'input' => 3.00,
131 'output' => 15.00
132 ],
133 'claude-3-5-haiku-20241022' => [
134 'input' => 0.80,
135 'output' => 4.00
136 ],
137 'claude-3-opus-20240229' => [
138 'input' => 15.00,
139 'output' => 75.00
140 ]
141 ];
142
143 /**
144 * Gemini pricing per 1M tokens (USD)
145 */
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 ],
165 // Gemini 2.5 models
166 'gemini-2.5-flash' => [
167 'input' => 0.30,
168 'output' => 2.50
169 ],
170 'gemini-2.5-flash-lite' => [
171 'input' => 0.10,
172 'output' => 0.40
173 ],
174 'gemini-2.5-pro' => [
175 'input' => 1.25,
176 'output' => 10.00
177 ],
178 // Gemini 2.0 models
179 'gemini-2.0-flash' => [
180 'input' => 0.10,
181 'output' => 0.40
182 ],
183 // Gemini 1.5 models
184 'gemini-1.5-flash' => [
185 'input' => 0.075,
186 'output' => 0.30
187 ],
188 'gemini-1.5-pro' => [
189 'input' => 1.25,
190 'output' => 5.00
191 ]
192 ];
193
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 /**
233 * Time saved estimates per action (minutes)
234 */
235 private const TIME_SAVED_ESTIMATES = [
236 'seo_metadata' => 20,
237 'content_analysis' => 15,
238 'content_brief' => 45,
239 'seo_score' => 10
240 ];
241
242 /**
243 * Constructor
244 */
245 public function __construct() {
246 $this->database = new Database();
247
248 // Configure caching for analytics endpoints
249 $this->set_cache_prefix('thinkrank_analytics_');
250 $this->set_cache_duration(600); // 10 minutes for analytics data
251
252 // Set up cache invalidation hooks
253 $this->setup_cache_invalidation();
254 }
255
256 /**
257 * Register REST API routes
258 *
259 * @return void
260 */
261 public function register_routes(): void {
262 // Overview metrics endpoint
263 register_rest_route('thinkrank/v1', '/analytics/overview', [
264 'methods' => 'GET',
265 'callback' => [$this, 'get_overview_metrics'],
266 'permission_callback' => [$this, 'check_permissions'],
267 'args' => [
268 'period' => [
269 'default' => '30d',
270 'type' => 'string',
271 'enum' => ['7d', '30d', '90d', 'all'],
272 'sanitize_callback' => 'sanitize_key'
273 ],
274 'user_id' => [
275 'default' => 0,
276 'type' => 'integer',
277 'sanitize_callback' => 'absint'
278 ]
279 ]
280 ]);
281
282 // Usage breakdown endpoint
283 register_rest_route('thinkrank/v1', '/analytics/usage', [
284 'methods' => 'GET',
285 'callback' => [$this, 'get_usage_breakdown'],
286 'permission_callback' => [$this, 'check_permissions'],
287 'args' => [
288 'period' => [
289 'default' => '30d',
290 'type' => 'string',
291 'enum' => ['7d', '30d', '90d', 'all'],
292 'sanitize_callback' => 'sanitize_key'
293 ],
294 'user_id' => [
295 'default' => 0,
296 'type' => 'integer',
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'
314 ]
315 ]
316 ]);
317
318 // Cost analysis endpoint
319 register_rest_route('thinkrank/v1', '/analytics/costs', [
320 'methods' => 'GET',
321 'callback' => [$this, 'get_cost_analysis'],
322 'permission_callback' => [$this, 'check_permissions'],
323 'args' => [
324 'period' => [
325 'default' => '30d',
326 'type' => 'string',
327 'enum' => ['7d', '30d', '90d', 'all'],
328 'sanitize_callback' => 'sanitize_key'
329 ],
330 'provider' => [
331 'default' => 'all',
332 'type' => 'string',
333 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'],
334 'sanitize_callback' => 'sanitize_key'
335 ],
336 'user_id' => [
337 'default' => 0,
338 'type' => 'integer',
339 'sanitize_callback' => 'absint'
340 ]
341 ]
342 ]);
343 }
344
345 /**
346 * Get overview metrics
347 *
348 * @param WP_REST_Request $request Request object
349 * @return WP_REST_Response|WP_Error Response object
350 */
351 public function get_overview_metrics(WP_REST_Request $request) {
352 $period = $request->get_param('period');
353 $user_id = $request->get_param('user_id') ?: get_current_user_id();
354
355 try {
356 // Use cached response wrapper for performance
357 $response_data = $this->cached_response(
358 'overview_metrics',
359 function() use ($period, $user_id) {
360 // Get date range for queries
361 $date_condition = $this->get_date_condition($period);
362
363 // Get AI usage metrics
364 $ai_metrics = $this->get_ai_usage_metrics($user_id, $date_condition);
365
366 // Get SEO metrics
367 $seo_metrics = $this->get_seo_metrics($user_id, $date_condition);
368
369 // Get content brief metrics
370 $brief_metrics = $this->get_content_brief_metrics($user_id, $date_condition);
371
372 // Calculate costs
373 $cost_data = $this->calculate_costs($ai_metrics['usage_data'] ?? []);
374
375 // Calculate time saved
376 $time_saved = $this->calculate_time_saved($ai_metrics['feature_breakdown'] ?? []);
377
378 return [
379 'success' => true,
380 'data' => [
381 'content_optimized' => $seo_metrics['content_optimized'],
382 'content_optimized_change' => $seo_metrics['content_optimized_change'],
383 'average_seo_score' => $seo_metrics['average_seo_score'],
384 'seo_score_change' => $seo_metrics['seo_score_change'],
385 'total_tokens' => $ai_metrics['total_tokens'],
386 'total_cost' => $cost_data['total'],
387 'cost_change' => $ai_metrics['cost_change'],
388 'time_saved' => $time_saved,
389 'time_saved_change' => $ai_metrics['time_saved_change'],
390 'ai_actions' => $ai_metrics['total_actions'],
391 'features_used_count' => $ai_metrics['features_used_count'],
392 'most_used_feature' => $ai_metrics['most_used_feature'],
393 'most_used_count' => $ai_metrics['most_used_count'],
394 'content_briefs' => $brief_metrics['total_briefs'],
395 'feature_breakdown' => $ai_metrics['feature_breakdown'],
396 'provider_breakdown' => $cost_data['by_provider']
397 ],
398 'period' => $period,
399 'generated_at' => current_time('c')
400 ];
401 },
402 ['period' => $period],
403 null, // Use default cache duration
404 $user_id
405 );
406
407 return new WP_REST_Response($response_data, 200);
408
409 } catch (\Exception $e) {
410 return new WP_Error(
411 'analytics_error',
412 'Failed to retrieve analytics data: ' . $e->getMessage(),
413 ['status' => 500]
414 );
415 }
416 }
417
418 /**
419 * Check permissions for analytics endpoints
420 *
421 * @param WP_REST_Request $request Request object
422 * @return bool|WP_Error Permission result
423 */
424 public function check_permissions(WP_REST_Request $request) {
425 // Check if user is logged in
426 if (!is_user_logged_in()) {
427 return new WP_Error(
428 'not_logged_in',
429 'You must be logged in to view analytics data.',
430 ['status' => 401]
431 );
432 }
433
434 // Check if user can edit posts (basic content management capability)
435 if (!current_user_can('edit_posts')) {
436 return new WP_Error(
437 'insufficient_permissions',
438 'You do not have permission to view analytics data.',
439 ['status' => 403]
440 );
441 }
442
443 // If requesting another user's data, check admin permissions
444 $requested_user_id = $request->get_param('user_id');
445 if ($requested_user_id && $requested_user_id !== get_current_user_id()) {
446 if (!current_user_can('manage_options')) {
447 return new WP_Error(
448 'insufficient_permissions',
449 'You do not have permission to view other users\' analytics data.',
450 ['status' => 403]
451 );
452 }
453 }
454
455 return true;
456 }
457
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 /**
485 * Set up cache invalidation hooks
486 *
487 * @since 1.0.0
488 * @return void
489 */
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
506 // Invalidate analytics cache when AI usage is logged
507 add_action('thinkrank_ai_usage_logged', [$this, 'invalidate_analytics_cache']);
508
509 // Invalidate analytics cache when SEO scores are updated
510 add_action('thinkrank_seo_score_updated', [$this, 'invalidate_analytics_cache']);
511
512 // Invalidate analytics cache when content briefs are created
513 add_action('thinkrank_content_brief_created', [$this, 'invalidate_analytics_cache']);
514 }
515
516 /**
517 * Invalidate analytics cache
518 *
519 * @since 1.0.0
520 * @return void
521 */
522 public function invalidate_analytics_cache(): void {
523 // Clear all analytics cache entries
524 $this->invalidate_cache_pattern('thinkrank_analytics_*');
525 }
526
527 /**
528 * Get the cutoff datetime string for a period.
529 * Returns null for 'all' (no date restriction).
530 *
531 * @param string $period Period string
532 * @return string|null Cutoff datetime in MySQL format, or null for all time
533 */
534 private function get_date_cutoff(string $period): ?string {
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 }
551 if ($days === null) {
552 return null;
553 }
554 return gmdate('Y-m-d H:i:s', strtotime("-{$days} days"));
555 }
556
557 /**
558 * Get date condition for SQL queries
559 *
560 * @deprecated Use get_date_cutoff() with parameterized queries instead.
561 * Kept for back-compat with get_previous_period_condition() parsing.
562 *
563 * @param string $period Period string
564 * @return string SQL date condition
565 */
566 private function get_date_condition(string $period): string {
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 }
579 }
580
581 /**
582 * Calculate costs from usage data
583 *
584 * @param array $usage_data Usage data array
585 * @return array Cost breakdown
586 */
587 private function calculate_costs(array $usage_data): array {
588 $costs = [
589 'openai' => 0,
590 'claude' => 0,
591 'gemini' => 0,
592 'openrouter' => 0,
593 // Costed only when the user told us what their endpoint charges;
594 // otherwise it stays 0 and the UI shows "—" rather than implying
595 // that a local model was free of charge or that we know the price.
596 'openai_compatible' => 0,
597 'total' => 0,
598 'by_provider' => []
599 ];
600
601 foreach ($usage_data as $usage) {
602 $tokens = (int) $usage['tokens_used'];
603 $provider = (string) $usage['provider'];
604
605 // Unknown provider: no pricing table, so it cannot be costed. Skip
606 // rather than let `+=` invent a key that the total below misses.
607 if (!isset($costs[$provider])) {
608 continue;
609 }
610
611 // Price at the model the request actually used. Reading only the
612 // provider meant every row was costed at that provider's default
613 // model, so this total disagreed with the per-record figures in
614 // the Usage Breakdown tab — by 4.5x on a gpt-4o-mini workload.
615 $metadata = !empty($usage['metadata']) ? json_decode((string) $usage['metadata'], true) : [];
616 $model = is_array($metadata) && !empty($metadata['actual_model'])
617 ? (string) $metadata['actual_model']
618 : $this->get_default_model($provider);
619
620 // Single source of truth for per-row pricing, shared with
621 // get_detailed_usage_breakdown() so both tabs always agree.
622 $costs[$provider] += $this->calculate_record_cost($provider, $tokens, $model);
623 }
624
625 $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'] + $costs['openai_compatible'];
626
627 // Report only providers that actually incurred cost. Emitting all four
628 // unconditionally meant a site with no AI usage rendered four ranked
629 // rows at "$0.0000 (0%)" — reading as "four providers were used and
630 // each was free" — and made the panel's own "No provider cost data"
631 // empty state unreachable.
632 $costs['by_provider'] = [];
633 foreach (['openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'] as $provider) {
634 if ($costs[$provider] <= 0) {
635 continue;
636 }
637
638 $costs['by_provider'][$provider] = [
639 'cost' => round($costs[$provider], 4),
640 'percentage' => $costs['total'] > 0
641 ? round(($costs[$provider] / $costs['total']) * 100, 1)
642 : 0
643 ];
644 }
645
646 return $costs;
647 }
648
649 /**
650 * Calculate time saved from feature usage
651 *
652 * @param array $feature_breakdown Feature usage breakdown
653 * @return int Time saved in minutes
654 */
655 private function calculate_time_saved(array $feature_breakdown): int {
656 $total_time_saved = 0;
657
658 foreach ($feature_breakdown as $feature => $count) {
659 $time_per_action = self::TIME_SAVED_ESTIMATES[$feature] ?? 15; // Default 15 minutes
660 $total_time_saved += $count * $time_per_action;
661 }
662
663 return $total_time_saved;
664 }
665
666 /**
667 * Get AI usage metrics from database
668 *
669 * @param int $user_id User ID
670 * @param string $date_condition SQL date condition
671 * @return array AI usage metrics
672 */
673 private function get_ai_usage_metrics(int $user_id, string $date_condition): array {
674 global $wpdb;
675
676 // Get table name and escape it properly (table names cannot be parameterized)
677 $table_name = esc_sql($this->database->get_table('ai_usage'));
678
679 $cutoff = $this->get_date_cutoff($this->resolve_period_from_condition($date_condition));
680
681 if ($cutoff !== null) {
682 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
683 $usage_data = $wpdb->get_results(
684 $wpdb->prepare(
685 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
686 "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
687 $user_id,
688 $cutoff
689 ),
690 ARRAY_A
691 );
692 } else {
693 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
694 $usage_data = $wpdb->get_results(
695 $wpdb->prepare(
696 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
697 "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d",
698 $user_id
699 ),
700 ARRAY_A
701 );
702 }
703
704 if (empty($usage_data)) {
705 // Must return the same shape as the populated path below —
706 // get_overview_metrics() reads every key unconditionally, so a
707 // short array here surfaces as undefined-key warnings and null
708 // fields for any user with no AI usage yet (i.e. a fresh install).
709 // The values mirror what the loop below produces for zero rows.
710 //
711 // The change fields are COMPUTED here rather than hardcoded to 0.
712 // An empty current window does not mean "nothing changed": a user
713 // whose usage fell from five actions last month to none this month
714 // was shown a 0 — rendered as the same em-dash a genuinely flat
715 // period gets — instead of the -100% that actually happened.
716 $previous = $this->get_previous_period_data($user_id, $date_condition);
717
718 return [
719 'total_actions' => 0,
720 'total_tokens' => 0,
721 'feature_breakdown' => [],
722 'features_used_count' => 0,
723 'most_used_feature' => '',
724 'most_used_count' => 0,
725 'usage_data' => [],
726 'cost_change' => $this->calculate_percentage_change(
727 array_key_exists('total_cost', $previous) ? $previous['total_cost'] : 0,
728 0.0
729 ),
730 'time_saved_change' => $this->calculate_percentage_change(
731 array_key_exists('time_saved', $previous) ? $previous['time_saved'] : 0,
732 0.0
733 )
734 ];
735 }
736
737 // Calculate feature breakdown and new metrics
738 $feature_breakdown = [];
739 $total_actions = 0;
740 $total_tokens = 0;
741
742 foreach ($usage_data as $row) {
743 $action = $row['action'];
744 $tokens = (int) $row['tokens_used'];
745
746 if (!isset($feature_breakdown[$action])) {
747 $feature_breakdown[$action] = 0;
748 }
749 $feature_breakdown[$action]++;
750 $total_actions++;
751 $total_tokens += $tokens;
752 }
753
754 // Calculate new metrics
755 $features_used_count = count($feature_breakdown);
756
757 // Find most used feature
758 $most_used_feature = '';
759 $most_used_count = 0;
760 foreach ($feature_breakdown as $feature => $count) {
761 if ($count > $most_used_count) {
762 $most_used_feature = $feature;
763 $most_used_count = $count;
764 }
765 }
766
767 // No success rate here on purpose. It used to be
768 // `$total_actions > 0 ? 100 : 0` — a constant presented as a
769 // measurement, and one that could only ever read 100% or 0%. Failed
770 // AI calls are never written to this table, so there is nothing to
771 // compute a rate from; the KPI card is gone until there is.
772
773 // Calculate changes from previous period
774 $previous_period_data = $this->get_previous_period_data($user_id, $date_condition);
775 // Note the lack of `?? 0`: a null here means "no previous period",
776 // and coalescing it to zero would turn that back into a fake 100%.
777 $cost_change = $this->calculate_percentage_change(
778 array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0,
779 $this->calculate_total_cost($usage_data)
780 );
781 $time_saved_change = $this->calculate_percentage_change(
782 array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0,
783 $this->calculate_time_saved($feature_breakdown)
784 );
785
786 return [
787 'total_actions' => $total_actions,
788 'total_tokens' => $total_tokens,
789 'feature_breakdown' => $feature_breakdown,
790 'features_used_count' => $features_used_count,
791 'most_used_feature' => $most_used_feature,
792 'most_used_count' => $most_used_count,
793 'usage_data' => $usage_data,
794 'cost_change' => $cost_change,
795 'time_saved_change' => $time_saved_change
796 ];
797 }
798
799 /**
800 * Get SEO metrics from database
801 *
802 * @param int $user_id User ID
803 * @param string $date_condition SQL date condition
804 * @return array SEO metrics
805 */
806 private function get_seo_metrics(int $user_id, string $date_condition): array {
807 global $wpdb;
808
809 $table_name = esc_sql($this->database->get_table('seo_scores'));
810 $cutoff = $this->get_date_cutoff($this->resolve_period_from_condition($date_condition));
811
812 if ($cutoff !== null) {
813 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
814 $result = $wpdb->get_row(
815 $wpdb->prepare(
816 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
817 "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",
818 $user_id,
819 $cutoff
820 ),
821 ARRAY_A
822 );
823 } else {
824 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
825 $result = $wpdb->get_row(
826 $wpdb->prepare(
827 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
828 "SELECT COUNT(DISTINCT post_id) as content_optimized, AVG(overall_score) as average_score FROM `{$table_name}` WHERE user_id = %d",
829 $user_id
830 ),
831 ARRAY_A
832 );
833 }
834
835 if (!$result || (int) $result['content_optimized'] === 0) {
836 // Same reasoning as the empty branch in get_ai_usage_metrics():
837 // an empty current window is not "no change". A user who
838 // optimized three posts last month and none this month should
839 // see -100%, not the em-dash a flat period gets — and for `all`
840 // there is no previous window, so the change is null.
841 $previous = $this->get_previous_seo_data($user_id, $date_condition);
842
843 return [
844 'content_optimized' => 0,
845 'average_seo_score' => 0,
846 'content_optimized_change' => $this->calculate_percentage_change(
847 array_key_exists('content_optimized', $previous) ? $previous['content_optimized'] : 0,
848 0.0
849 ),
850 'seo_score_change' => $this->calculate_percentage_change(
851 array_key_exists('average_seo_score', $previous) ? $previous['average_seo_score'] : 0,
852 0.0
853 )
854 ];
855 }
856
857 // Calculate changes from previous period
858 $previous_seo_data = $this->get_previous_seo_data($user_id, $date_condition);
859 // As above: no `?? 0`, so a null "no previous period" survives.
860 $content_optimized_change = $this->calculate_percentage_change(
861 array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0,
862 (int) $result['content_optimized']
863 );
864 $seo_score_change = $this->calculate_percentage_change(
865 array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0,
866 round((float) $result['average_score'], 1)
867 );
868
869 return [
870 'content_optimized' => (int) $result['content_optimized'],
871 'average_seo_score' => round((float) $result['average_score'], 1),
872 'content_optimized_change' => $content_optimized_change,
873 'seo_score_change' => $seo_score_change
874 ];
875 }
876
877 /**
878 * Get content brief metrics from database
879 *
880 * @param int $user_id User ID
881 * @param string $date_condition SQL date condition
882 * @return array Content brief metrics
883 */
884 private function get_content_brief_metrics(int $user_id, string $date_condition): array {
885 global $wpdb;
886
887 $table_name = esc_sql($this->database->get_table('content_briefs'));
888 $cutoff = $this->get_date_cutoff($this->resolve_period_from_condition($date_condition));
889
890 if ($cutoff !== null) {
891 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
892 $result = $wpdb->get_var(
893 $wpdb->prepare(
894 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
895 "SELECT COUNT(*) as total_briefs FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
896 $user_id,
897 $cutoff
898 )
899 );
900 } else {
901 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
902 $result = $wpdb->get_var(
903 $wpdb->prepare(
904 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
905 "SELECT COUNT(*) as total_briefs FROM `{$table_name}` WHERE user_id = %d",
906 $user_id
907 )
908 );
909 }
910
911 return [
912 'total_briefs' => (int) $result ?: 0
913 ];
914 }
915
916 /**
917 * Get detailed usage breakdown
918 *
919 * @param WP_REST_Request $request Request object
920 * @return WP_REST_Response|WP_Error Response object
921 */
922 public function get_usage_breakdown(WP_REST_Request $request) {
923 try {
924 // Mirrors get_overview_metrics(). The two endpoints declared the
925 // same `user_id` argument but only overview honoured it, so the
926 // same query string described two different users depending on
927 // which one you asked. check_permissions() already requires
928 // manage_options before another user's id is accepted.
929 $user_id = $request->get_param('user_id') ?: get_current_user_id();
930 $period = $request->get_param('period') ?? '30d';
931 // `(int)` binds tighter than `??`, so `(int) null` is 0 and the
932 // `?? 20` fallback was unreachable — per_page silently defaulted to
933 // the max(10, 0) floor of 10 rather than the 20 it advertises, and
934 // page to max(1, 0) = 1 by luck rather than intent (#394).
935 $page = max(1, (int) ($request->get_param('page') ?? 1));
936 $per_page = min(100, max(10, (int) ($request->get_param('per_page') ?? 20)));
937 $offset = ($page - 1) * $per_page;
938
939 // Get date range for queries
940 $date_condition = $this->get_date_condition($period);
941
942 // Get detailed usage breakdown
943 $usage_data = $this->get_detailed_usage_breakdown($user_id, $date_condition, $per_page, $offset);
944 $total_records = $this->get_usage_breakdown_count($user_id, $date_condition);
945
946 return new WP_REST_Response([
947 'success' => true,
948 'data' => [
949 'usage_records' => $usage_data,
950 'pagination' => [
951 'page' => $page,
952 'per_page' => $per_page,
953 'total_records' => $total_records,
954 // (int) so it serialises as 2, not 2.0.
955 'total_pages' => $per_page > 0 ? (int) ceil($total_records / $per_page) : 0
956 ],
957 'period' => $period
958 ]
959 ], 200);
960
961 } catch (\Exception $e) {
962 return new WP_Error(
963 'usage_breakdown_failed',
964 'Failed to get usage breakdown: ' . $e->getMessage(),
965 ['status' => 500]
966 );
967 }
968 }
969
970 /**
971 * Get cost analysis (placeholder for Phase 2)
972 *
973 * @param WP_REST_Request $request Request object
974 * @return WP_REST_Response|WP_Error Response object
975 */
976 public function get_cost_analysis(WP_REST_Request $request) {
977 // Return 200 with success:false so the frontend can render an
978 // "unavailable" state — apiFetch rejects on non-2xx, which would
979 // otherwise surface as a generic hard error.
980 return new WP_REST_Response([
981 'success' => false,
982 'data' => null,
983 'message' => 'Cost analysis is not yet implemented.'
984 ], 200);
985 }
986
987 /**
988 * Calculate total cost from usage data
989 *
990 * @param array $usage_data Usage data array
991 * @return float Total cost
992 */
993 private function calculate_total_cost(array $usage_data): float {
994 $cost_data = $this->calculate_costs($usage_data);
995 return $cost_data['total'];
996 }
997
998 /**
999 * Calculate percentage change between two values
1000 *
1001 * @param float $old_value Previous period value
1002 * @param float $new_value Current period value
1003 * @return float Percentage change
1004 */
1005 private function calculate_percentage_change($old_value, float $new_value): ?float {
1006 // No previous period at all (the 'all' range).
1007 if (null === $old_value) {
1008 return null;
1009 }
1010
1011 if ((float) $old_value === 0.0) {
1012 // Growth from nothing has no percentage. Reporting a flat 100%
1013 // dressed it up as a measured change; null lets the UI say "new"
1014 // (or say nothing) instead of inventing a number.
1015 return $new_value > 0 ? null : 0.0;
1016 }
1017
1018 return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1);
1019 }
1020
1021 /**
1022 * Get previous period data for comparison
1023 *
1024 * @param int $user_id User ID
1025 * @param string $current_date_condition Current period date condition
1026 * @return array Previous period data
1027 */
1028 private function get_previous_period_data(int $user_id, string $current_date_condition): array {
1029 global $wpdb;
1030
1031 // Get table name and escape it properly (table names cannot be parameterized)
1032 $table_name = esc_sql($this->database->get_table('ai_usage'));
1033
1034 // Extract the interval from current date condition to calculate previous period
1035 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1036
1037 // No preceding window: report "not comparable" rather than querying a
1038 // made-up one.
1039 if (null === $previous_date_condition) {
1040 return ['total_cost' => null, 'time_saved' => null];
1041 }
1042
1043 // Prepare and execute query with proper parameter binding to prevent SQL injection
1044 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1045 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
1046 $usage_data = $wpdb->get_results(
1047 $wpdb->prepare("
1048 SELECT
1049 provider,
1050 action,
1051 tokens_used,
1052 metadata
1053 FROM `{$table_name}`
1054 WHERE user_id = %d
1055 {$previous_date_condition}
1056 ", $user_id),
1057 ARRAY_A
1058 );
1059 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1060
1061 if (empty($usage_data)) {
1062 return ['total_cost' => 0, 'time_saved' => 0];
1063 }
1064
1065 // Calculate feature breakdown for time saved
1066 $feature_breakdown = [];
1067 foreach ($usage_data as $row) {
1068 $action = $row['action'];
1069 if (!isset($feature_breakdown[$action])) {
1070 $feature_breakdown[$action] = 0;
1071 }
1072 $feature_breakdown[$action]++;
1073 }
1074
1075 return [
1076 'total_cost' => $this->calculate_total_cost($usage_data),
1077 'time_saved' => $this->calculate_time_saved($feature_breakdown)
1078 ];
1079 }
1080
1081 /**
1082 * Get detailed usage breakdown with pagination
1083 *
1084 * @param int $user_id User ID
1085 * @param string $date_condition SQL date condition
1086 * @param int $limit Number of records to return
1087 * @param int $offset Offset for pagination
1088 * @return array Detailed usage records
1089 */
1090 private function get_detailed_usage_breakdown(int $user_id, string $date_condition, int $limit, int $offset): array {
1091 global $wpdb;
1092
1093 $table_name = esc_sql($this->database->get_table('ai_usage'));
1094
1095 $sql = "
1096 SELECT
1097 id,
1098 provider,
1099 action,
1100 tokens_used,
1101 post_id,
1102 metadata,
1103 created_at
1104 FROM `{$table_name}`
1105 WHERE user_id = %d
1106 {$date_condition}
1107 ORDER BY created_at DESC
1108 LIMIT %d OFFSET %d
1109 ";
1110
1111 // 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
1112 $usage_data = $wpdb->get_results(
1113 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1114 $wpdb->prepare($sql, $user_id, $limit, $offset),
1115 ARRAY_A
1116 );
1117
1118 // Process and enrich the data
1119 $processed_data = [];
1120 foreach ($usage_data as $record) {
1121 $metadata = !empty($record['metadata']) ? json_decode($record['metadata'], true) : [];
1122 $model = $metadata['actual_model'] ?? $this->get_default_model($record['provider']);
1123 $cost = $this->calculate_record_cost($record['provider'], (int) $record['tokens_used'], $model);
1124
1125 $processed_data[] = [
1126 'id' => (int) $record['id'],
1127 'provider' => $record['provider'],
1128 'model' => $model,
1129 'action' => $record['action'],
1130 'tokens_used' => (int) $record['tokens_used'],
1131 'estimated_cost' => $cost,
1132 'post_id' => $record['post_id'] ? (int) $record['post_id'] : null,
1133 'created_at' => $record['created_at'],
1134 'formatted_date' => wp_date('M j, Y g:i A', strtotime($record['created_at']))
1135 ];
1136 }
1137
1138 return $processed_data;
1139 }
1140
1141 /**
1142 * Get total count of usage records for pagination
1143 *
1144 * @param int $user_id User ID
1145 * @param string $date_condition SQL date condition
1146 * @return int Total record count
1147 */
1148 private function get_usage_breakdown_count(int $user_id, string $date_condition): int {
1149 global $wpdb;
1150
1151 $table_name = esc_sql($this->database->get_table('ai_usage'));
1152
1153 $sql = "
1154 SELECT COUNT(*)
1155 FROM `{$table_name}`
1156 WHERE user_id = %d
1157 {$date_condition}
1158 ";
1159
1160 // 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
1161 $count = $wpdb->get_var(
1162 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1163 $wpdb->prepare($sql, $user_id)
1164 );
1165
1166 return (int) $count;
1167 }
1168
1169 /**
1170 * Calculate cost for a single record using the robust pricing helper
1171 *
1172 * @param string $provider AI provider
1173 * @param int $tokens_used Number of tokens used
1174 * @param string $model Optional specific model name
1175 * @return float Estimated cost
1176 */
1177 private function calculate_record_cost(string $provider, int $tokens_used, string $model = ''): float {
1178 // Get pricing using the robust helper method
1179 $pricing = $this->get_model_pricing($provider, $model);
1180
1181 if (!$pricing) {
1182 return 0.0;
1183 }
1184
1185 // Estimate 70% input, 30% output tokens
1186 $input_tokens = $tokens_used * 0.7;
1187 $output_tokens = $tokens_used * 0.3;
1188
1189 return (($input_tokens / 1000000) * $pricing['input']) +
1190 (($output_tokens / 1000000) * $pricing['output']);
1191 }
1192
1193 /**
1194 * Get pricing for any model with intelligent fallbacks
1195 *
1196 * @param string $provider AI provider
1197 * @param string $model Model name (optional)
1198 * @return array|null Pricing array with 'input' and 'output' keys, or null if not found
1199 */
1200 private function get_model_pricing(string $provider, string $model = ''): ?array {
1201 switch ($provider) {
1202 case 'openai':
1203 // Try specific model first, fallback to default
1204 if ($model && isset(self::OPENAI_PRICING[$model])) {
1205 return self::OPENAI_PRICING[$model];
1206 }
1207 return self::OPENAI_PRICING['gpt-4o'] ?? null;
1208
1209 case 'claude':
1210 // Try specific model first, fallback to recommended default
1211 if ($model && isset(self::CLAUDE_PRICING[$model])) {
1212 return self::CLAUDE_PRICING[$model];
1213 }
1214 return self::CLAUDE_PRICING['claude-sonnet-5'] ??
1215 self::CLAUDE_PRICING['claude-sonnet-4-6'] ?? null;
1216
1217 case 'gemini':
1218 // Try specific model first, fallback to default
1219 if ($model && isset(self::GEMINI_PRICING[$model])) {
1220 return self::GEMINI_PRICING[$model];
1221 }
1222 return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null;
1223
1224 case 'openrouter':
1225 // Try specific model first, fallback to default
1226 if ($model && isset(self::OPENROUTER_PRICING[$model])) {
1227 return self::OPENROUTER_PRICING[$model];
1228 }
1229 return self::OPENROUTER_PRICING['openai/gpt-4o-mini'] ?? null;
1230
1231 case 'openai_compatible':
1232 // There is no price table for someone else's server: it may be
1233 // a free local model, an Azure contract or a hosted open model.
1234 // The only honest number is the one the administrator entered,
1235 // as a flat per-1M-token rate applied to both directions.
1236 //
1237 // Read at report time, so changing the rate (or repointing the
1238 // provider at another server) re-costs past rows too. Accepted:
1239 // storing a price per row would mean a schema change for an
1240 // estimate the administrator typed in the first place.
1241 $price = (float) \ThinkRank\Core\Settings::instance()->get('openai_compatible_price_per_million', 0);
1242
1243 return $price > 0 ? ['input' => $price, 'output' => $price] : null;
1244
1245 default:
1246 return null;
1247 }
1248 }
1249
1250 /**
1251 * Get default model for provider using proper aliases
1252 *
1253 * @param string $provider AI provider
1254 * @return string Default model name
1255 */
1256 private function get_default_model(string $provider): string {
1257 switch ($provider) {
1258 case 'openai':
1259 return \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL;
1260 case 'claude':
1261 return \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL;
1262 case 'gemini':
1263 return \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL;
1264 case 'openrouter':
1265 return \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL;
1266 case 'openai_compatible':
1267 // Whatever the user pointed us at; there is no default.
1268 return (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_model', '');
1269 default:
1270 return 'unknown';
1271 }
1272 }
1273
1274 /**
1275 * Get previous SEO data for comparison
1276 *
1277 * @param int $user_id User ID
1278 * @param string $current_date_condition Current period date condition
1279 * @return array Previous SEO data
1280 */
1281 private function get_previous_seo_data(int $user_id, string $current_date_condition): array {
1282 global $wpdb;
1283
1284 // Get table name and escape it properly (table names cannot be parameterized)
1285 $table_name = esc_sql($this->database->get_table('seo_scores'));
1286
1287 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1288
1289 // See get_previous_period_data(): no preceding window, no comparison.
1290 if (null === $previous_date_condition) {
1291 return ['content_optimized' => null, 'average_seo_score' => null];
1292 }
1293
1294 // Prepare and execute query with proper parameter binding to prevent SQL injection
1295 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1296 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
1297 $result = $wpdb->get_row(
1298 $wpdb->prepare("
1299 SELECT
1300 COUNT(DISTINCT post_id) as content_optimized,
1301 AVG(overall_score) as average_score
1302 FROM `{$table_name}`
1303 WHERE user_id = %d
1304 {$previous_date_condition}
1305 ", $user_id),
1306 ARRAY_A
1307 );
1308 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1309
1310 if (!$result) {
1311 return ['content_optimized' => 0, 'average_seo_score' => 0];
1312 }
1313
1314 return [
1315 'content_optimized' => (int) $result['content_optimized'],
1316 'average_seo_score' => round((float) $result['average_score'], 1)
1317 ];
1318 }
1319
1320 /**
1321 * Resolve a period key from a legacy SQL date condition string.
1322 * Used internally so the new parameterized helpers can derive the period.
1323 *
1324 * @param string $condition Legacy date condition string
1325 * @return string Period key
1326 */
1327 private function resolve_period_from_condition(string $condition): string {
1328 if (strpos($condition, 'INTERVAL 7') !== false) { return '7d';
1329 }
1330 if (strpos($condition, 'INTERVAL 30') !== false) { return '30d';
1331 }
1332 if (strpos($condition, 'INTERVAL 90') !== false) { return '90d';
1333 }
1334 if (empty(trim($condition))) { return 'all';
1335 }
1336 return '30d';
1337 }
1338
1339 /**
1340 * Convert current period condition to previous period condition
1341 *
1342 * Returns null when there is no preceding window to compare against.
1343 * `all` produces an empty date condition, which used to fall through to a
1344 * hardcoded 30–60 day fallback — so "all time" was compared against an
1345 * arbitrary month and reported a large, meaningless increase. "No
1346 * comparison" is now representable instead of being a parse failure.
1347 *
1348 * @param string $current_condition Current period SQL condition
1349 * @return string|null Previous period SQL condition, or null when none exists
1350 */
1351 private function get_previous_period_condition(string $current_condition): ?string {
1352 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1353 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1354 $interval = (int) $matches[1];
1355 $unit = $matches[2];
1356
1357 // Calculate previous period: if current is last 30 days, previous is 30-60 days ago
1358 $start_interval = $interval * 2;
1359 $end_interval = $interval;
1360
1361 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1362 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1363 }
1364
1365 // No interval means no window — 'all'. Comparing every record ever
1366 // against a fabricated 30-day slice is not a trend.
1367 return null;
1368 }
1369 }
1370