PluginProbe
PowerPress Podcasting plugin by Blubrry / trunk
PowerPress Podcasting plugin by Blubrry vtrunk
11.17.9 11.17.8 11.17.7 11.17.6 11.17.4 11.17.3 11.17.2 11.17.1 11.17 11.16.11 11.16.10 11.16.9 11.16.8 11.16.7 11.16.6 11.16.5 11.16.4 11.16.3 11.16.2 11.16.1 11.9.13 11.9.14 11.9.15 11.9.16 11.9.17 All 383 releases
powerpress / powerpressadmin-program-card.class.php

powerpressadmin-program-card.class.php in PowerPress Podcasting plugin by Blubrry trunk, at powerpressadmin-program-card.class.php

1,201 lines 52.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * PowerPress Program Card
4 * unified component that renders stats widget and show info card together
5 * handles program data fetching, stats api calls, and rendering
6 */
7
8 class PowerPressProgramCard
9 {
10 // CONFIGURATION
11 private $general_settings;
12 private $feed_settings;
13 private $feed_slug;
14 private $deferred = false;
15 private $has_blubrry_auth = false;
16
17 // PROGRAM DATA
18 private $network_mode = false;
19 private $program_keyword = '';
20 private $default_program_keyword = '';
21 private $programs = [];
22 private $programs_full = [];
23 private $program_info = [];
24
25 // STATS DATA
26 private $stats_content = [];
27 // URL CONFIG
28 private $stats_base_url = 'https://stats.blubrry.com/';
29
30 /* ==============
31 INITIALIZATION
32 ============== */
33
34 public function __construct(string $feed_slug = 'podcast', string $override_program = '', bool $deferred = false) {
35 // 1. LOAD SETTINGS
36 $this->feed_slug = $feed_slug;
37 $general = get_option('powerpress_general');
38 $this->general_settings = is_array($general) ? $general : [];
39
40 if (defined('POWERPRESS_BLUBRRY_STATS_URL')) $this->stats_base_url = rtrim(POWERPRESS_BLUBRRY_STATS_URL, '/') . '/';
41
42 $this->network_mode = !empty($this->general_settings['network_mode']);
43 $this->default_program_keyword = !empty($this->general_settings['blubrry_program_keyword'])
44 ? $this->general_settings['blubrry_program_keyword']
45 : '';
46
47 // 2. CHECK AUTH
48 $creds = get_option('powerpress_creds');
49 $userpass = !empty($this->general_settings['blubrry_auth']) ? $this->general_settings['blubrry_auth'] : '';
50 $this->has_blubrry_auth = !empty($creds) || !empty($userpass);
51
52 // 3. CHECK PERMISSIONS
53 $stats_enabled = $this->should_show_stats();
54 $this->deferred = $deferred && $this->has_blubrry_auth && $stats_enabled;
55 $this->load_feed_settings();
56
57 // 4. LOAD DATA
58 if (!$this->has_blubrry_auth) {
59 $this->program_keyword = $override_program ?: $this->default_program_keyword;
60 } elseif (!$this->deferred) {
61 $this->load_stats_data($override_program);
62 } else {
63 $this->program_keyword = $override_program ?: $this->default_program_keyword;
64 }
65 $this->program_info = $this->build_program_info();
66 }
67
68 /** checks permission flags and user capabilities */
69 private function should_show_stats(): bool {
70 if (!empty($this->general_settings['disable_dashboard_stats'])) return false;
71 if (!empty($this->general_settings['use_caps']) && !current_user_can('view_podcast_stats')) return false;
72 return true;
73 }
74
75 /** loads feed settings for current slug with defaults applied */
76 private function load_feed_settings(): void {
77 $feed = get_option($this->feed_slug === 'podcast' ? 'powerpress_feed' : "powerpress_feed_{$this->feed_slug}");
78 $this->feed_settings = is_array($feed) ? $feed : [];
79
80 if (function_exists('powerpress_default_settings')) {
81 $this->feed_settings = powerpress_default_settings($this->feed_settings, 'editfeed');
82 }
83 }
84
85 public function get_program_keyword(): string {
86 return $this->program_keyword;
87 }
88
89 /* ============
90 PROGRAM DATA
91 ============ */
92
93 /** fetches program list from api w/ transient caching */
94 private function fetch_programs(): array {
95 $settings = $this->general_settings;
96 $creds = get_option('powerpress_creds');
97
98 // 1. CHECK CACHE
99 $cached_programs = get_transient('powerpress_programs_list');
100 if ($cached_programs !== false) {
101 return $cached_programs;
102 }
103
104 // 2. FETCH API
105 $programs = [];
106 $api_error = null;
107
108 require_once POWERPRESS_ABSPATH . '/powerpressadmin-auth.class.php';
109 $auth = new PowerPressAuth();
110 $api_url_array = powerpress_get_api_array();
111
112 $results = powerpress_api_request('/2/service/index.json', [], [], $settings, $creds, $auth, $api_url_array, 15);
113
114 if ($results && is_array($results)) {
115 if (isset($results['error'])) {
116 $err = $results['message'] ?? $results['error'];
117 if (!empty($err)) {
118 $reset_url = wp_nonce_url(
119 admin_url('admin.php?page=powerpress/powerpressadmin_tools.php&action=powerpress-reset-blubrry-connection'),
120 'powerpress-reset-blubrry-connection'
121 );
122 $err = sprintf(
123 __('Service unavailable (%1$s). <a href="%2$s">Reset and reconnect your Blubrry account</a>.', 'powerpress'),
124 esc_html((string)$err),
125 esc_url($reset_url)
126 );
127 }
128 $api_error = $err;
129 } else {
130 foreach ($results as $row) {
131 if (isset($row['program_keyword']) && isset($row['program_title'])) {
132 $programs[$row['program_keyword']] = $row['program_title'];
133 // store basic data for program_id lookup
134 $this->programs_full[$row['program_keyword']] = $row;
135 }
136 }
137 }
138 }
139
140 // 3. SAVE CACHE
141 if (!empty($programs)) {
142 set_transient('powerpress_programs_list', $programs, HOUR_IN_SECONDS);
143 } else if ($api_error) {
144 set_transient('powerpress_programs_api_error', $api_error, 5 * MINUTE_IN_SECONDS);
145 }
146
147 return $programs;
148 }
149
150 /**
151 * fetches detailed program info from API (lazy-loaded per program)
152 */
153 private function fetch_program_info(string $keyword): ?array {
154 if (empty($keyword)) return null;
155
156 // 1. CHECK CACHE
157 $cache_key = 'powerpress_program_info_' . md5($keyword);
158 $cached_info = get_transient($cache_key);
159 if ($cached_info !== false) {
160 return $cached_info;
161 }
162
163 // 2. FETCH API
164 $settings = $this->general_settings;
165 $creds = get_option('powerpress_creds');
166
167 require_once POWERPRESS_ABSPATH . '/powerpressadmin-auth.class.php';
168 $auth = new PowerPressAuth();
169 $api_url_array = powerpress_get_api_array();
170
171 $enc_keyword = urlencode($keyword);
172 $results = powerpress_api_request("/2/program/{$enc_keyword}/info.json", [], [], $settings, $creds, $auth, $api_url_array, 5);
173
174 if (!$results || !is_array($results) || isset($results['error'])) {
175 return null;
176 }
177
178 // 3. SAVE CACHE (30 min for program info)
179 set_transient($cache_key, $results, 30 * MINUTE_IN_SECONDS);
180
181 return $results;
182 }
183
184 /**
185 * builds program info for display
186 * default program: WP settings first, API as fallback
187 * non-default programs: API only (no fallback to default's WP data)
188 */
189 private function build_program_info(): array {
190 // 1. FETCH API DATA (skip when deferred - will load via AJAX later)
191 $api = [];
192 if (!$this->deferred && !empty($this->program_keyword) && $this->has_blubrry_auth) {
193 $program = $this->fetch_program_info($this->program_keyword);
194 if ($program) {
195 $api = $program;
196 // store in programs_full for program_id lookups elsewhere
197 $this->programs_full[$this->program_keyword] = array_merge(
198 $this->programs_full[$this->program_keyword] ?? [],
199 $program
200 );
201 }
202 }
203
204 // 2. BASE: start with API values
205 $default_image = powerpress_get_root_url() . 'images/pts_cover.jpg';
206
207 $title = $api['program_title'] ?? '';
208 $author = $api['author'] ?? '';
209 $description = $api['description'] ?? '';
210 $category = $api['category'] ?? '';
211 $feed_url = $api['feed_url'] ?? '';
212 $episode_count = isset($api['episode_count']) ? intval($api['episode_count']) : 0;
213 $created_date = $api['created_date'] ?? '';
214 $last_update = $api['last_update'] ?? '';
215
216 $api_image = $api['artwork_url'] ?? '';
217 $has_real_api_image = !$this->is_placeholder_image($api_image);
218 $image = $has_real_api_image ? $api_image : $default_image;
219
220 // 3. OVERLAY: default program overrides with WP settings
221 $is_default = ($this->program_keyword === $this->default_program_keyword);
222 if ($is_default) {
223 $wp_settings = $this->feed_settings;
224
225 if (!empty($wp_settings['title'])) $title = $wp_settings['title'];
226 if (!empty($wp_settings['itunes_talent_name'])) $author = $wp_settings['itunes_talent_name'];
227 if (!empty($wp_settings['description'])) $description = $wp_settings['description'];
228
229 // wp image if real -> api image if real -> wp default
230 $wp_image = !empty($wp_settings['itunes_image']) ? $wp_settings['itunes_image'] : ($wp_settings['rss2_image'] ?? '');
231 if (!$this->is_placeholder_image($wp_image)) {
232 $image = $wp_image;
233 } elseif ($has_real_api_image) {
234 $image = $api_image;
235 }
236
237 if (!empty($wp_settings['apple_cat_1'])) {
238 $categories = powerpress_apple_categories(true);
239 $category = $categories[$wp_settings['apple_cat_1']] ?? $wp_settings['apple_cat_1'];
240 }
241
242 $feed_url = $this->network_mode ? '' : get_feed_link($this->feed_slug);
243 }
244
245 if (empty($episode_count)) {
246 $episode_count = powerpress_admin_episodes_per_feed($this->feed_slug);
247 }
248
249 return [
250 'title' => $title,
251 'author' => $author,
252 'description' => $description,
253 'image' => $image,
254 'category' => $category,
255 'feed_url' => $feed_url,
256 'episode_count' => $episode_count,
257 'created_date' => $created_date,
258 'last_update' => $last_update
259 ];
260 }
261
262 /* ==========
263 STATS DATA
264 ========== */
265
266 private function load_stats_data(string $override_program = ''): void {
267 $settings = $this->general_settings;
268 $creds = get_option('powerpress_creds');
269
270 // 1. VALIDATE PERMISSIONS
271 if (!$this->should_show_stats()) return;
272
273 $userpass = !empty($settings['blubrry_auth']) ? $settings['blubrry_auth'] : '';
274
275 // 2. CHECK AUTH
276 if (!$userpass && !$creds) {
277 $this->program_keyword = $this->default_program_keyword;
278 return;
279 }
280
281 // 3. LOAD PROGRAMS
282 $this->programs = $this->fetch_programs();
283
284 // 4. RESOLVE KEYWORD
285 $keyword = '';
286
287 if ($this->network_mode) {
288 $program_keys = array_map('strval', array_keys($this->programs));
289
290 if ($override_program && in_array((string)$override_program, $program_keys, true)) {
291 $keyword = (string)$override_program;
292 } else {
293 $default_kw = $this->default_program_keyword ? (string)$this->default_program_keyword : '';
294 if ($default_kw && in_array($default_kw, $program_keys, true)) {
295 $keyword = $default_kw;
296 }
297 }
298
299 $this->program_keyword = $keyword;
300
301 if (empty($this->programs)) {
302 $api_error = get_transient('powerpress_programs_api_error');
303 $stats_url = esc_url($this->stats_base_url);
304 if ($api_error) {
305 $error_msg = $api_error;
306 } else {
307 $visit = __('Multi-program mode is enabled but no programs were found. Please visit', 'powerpress');
308 $suffix = __('to see your statistics.', 'powerpress');
309 $error_msg = "{$visit} <a href=\"{$stats_url}\" target=\"_blank\">Blubrry Stats</a> {$suffix}";
310 }
311 $this->stats_content = ['error' => $error_msg];
312 return;
313 }
314 } else {
315 $keyword = $this->default_program_keyword;
316 $this->program_keyword = $keyword;
317 }
318
319 if (empty($keyword)) {
320 $stats_url = esc_url($this->stats_base_url);
321 $visit = __('No program selected. Please visit', 'powerpress');
322 $suffix = __('to see your statistics.', 'powerpress');
323 $this->stats_content = ['error' => "{$visit} <a href=\"{$stats_url}\" target=\"_blank\">Blubrry Stats</a> {$suffix}"];
324 return;
325 }
326
327 // 5. LOAD CACHE
328 $days_key = "{$keyword}_days";
329 $meta_key = "{$keyword}_meta";
330
331 $stats_cached = get_option('powerpress_stats');
332 if (!is_array($stats_cached)) $stats_cached = [];
333
334 $stats_cached = $this->migrate_stats_cache($stats_cached, $keyword);
335
336 $cached_days = isset($stats_cached[$days_key]) && is_array($stats_cached[$days_key])
337 ? $stats_cached[$days_key]
338 : [];
339 $cached_meta = isset($stats_cached[$meta_key]) && is_array($stats_cached[$meta_key])
340 ? $stats_cached[$meta_key]
341 : [];
342
343 // fetch window
344 $max_retention = 60;
345
346 foreach (array_keys($cached_days) as $date) {
347 if (!$this->is_valid_cache_date($date)) {
348 unset($cached_days[$date]);
349 }
350 }
351
352 // 6. CALCULATE DELTA
353 $today = date('Y-m-d');
354 $today_ttl = 30 * 60; // 30 min ttl for today's data
355 $last_fetch = isset($cached_meta['last_fetch']) ? (int)$cached_meta['last_fetch'] : 0;
356 $today_needs_refresh = (time() - $last_fetch) > $today_ttl;
357
358 $delta = $this->calculate_stats_delta($cached_days, $today, $max_retention);
359 $fetch_days = $delta['fetch_days'];
360 $is_full_fetch = $delta['is_full_fetch'];
361
362 // use cached data if fresh enough
363 if (!empty($cached_days) && !$today_needs_refresh && !$is_full_fetch) {
364 $this->stats_content = $this->build_stats_content_from_cache($cached_days, $cached_meta, $max_retention);
365 return;
366 }
367
368 // 7. FETCH API
369 require_once(POWERPRESS_ABSPATH . '/powerpressadmin-auth.class.php');
370 $auth = new PowerPressAuth();
371 $api_url_array = powerpress_get_api_array();
372
373 $enc_keyword = urlencode($keyword);
374 $new_content = powerpress_api_request(
375 "/2/stats/{$enc_keyword}/data.json?days={$fetch_days}&include=daily,totals,averages,trends",
376 [],
377 [],
378 $settings,
379 $creds,
380 $auth,
381 $api_url_array,
382 2
383 );
384
385 // 8. PROCESS RESPONSE
386 if (!$new_content) {
387 if (!empty($cached_days)) {
388 $this->stats_content = $this->build_stats_content_from_cache($cached_days, $cached_meta, $max_retention);
389 } else {
390 $this->stats_content = ['error' => 'Unable to retrieve statistics'];
391 }
392 return;
393 }
394
395 if (isset($new_content['error'])) {
396 if (strpos($new_content['error'], 'Unable to locate program') !== false ||
397 strpos($new_content['error'], 'No statistics') !== false) {
398 $this->stats_content = $this->get_empty_stats_content();
399 $no_stats_programs = get_transient('powerpress_no_stats_programs');
400 if (!is_array($no_stats_programs)) $no_stats_programs = [];
401 $no_stats_programs[$keyword] = true;
402 set_transient('powerpress_no_stats_programs', $no_stats_programs, DAY_IN_SECONDS);
403 } else {
404 $err = $new_content['error'];
405 if (!empty($err)) {
406 $reset_url = wp_nonce_url(
407 admin_url('admin.php?page=powerpress/powerpressadmin_tools.php&action=powerpress-reset-blubrry-connection'),
408 'powerpress-reset-blubrry-connection'
409 );
410 $err = sprintf(
411 __('Statistics unavailable (%1$s). <a href="%2$s">Reset and reconnect your Blubrry account</a>.', 'powerpress'),
412 esc_html((string)$err),
413 esc_url($reset_url)
414 );
415 }
416 $this->stats_content = ['error' => $err];
417 }
418 return;
419 }
420
421 // 9. UPDATE CACHE (map data API response to internal format)
422 $new_day_data = isset($new_content['daily']) ? $new_content['daily'] : [];
423 $merged_days = $this->merge_stats_days_from_data_api($cached_days, $new_day_data);
424 $merged_days = $this->prune_stats_days($merged_days, $today, $max_retention);
425
426 $stats_cached[$days_key] = $merged_days;
427 $stats_cached[$meta_key] = [
428 'last_fetch' => time(),
429 'last_date' => $today,
430 'stats_tier' => $new_content['stats_tier'] ?? 'basic',
431 'program_total' => $new_content['totals']['all_time'] ?? 0,
432 'month_average' => $new_content['averages']['thirty_day'] ?? 0,
433 'month_average_change' => $new_content['trends']['thirty_day_vs_prior'] ?? 'same',
434 ];
435 update_option('powerpress_stats', $stats_cached);
436
437 // clear no-stats flag if we got data
438 $no_stats_programs = get_transient('powerpress_no_stats_programs');
439 if (is_array($no_stats_programs) && isset($no_stats_programs[$keyword])) {
440 unset($no_stats_programs[$keyword]);
441 set_transient('powerpress_no_stats_programs', $no_stats_programs, DAY_IN_SECONDS);
442 }
443
444 $this->stats_content = $this->build_stats_content_from_cache($merged_days, $stats_cached[$meta_key], $max_retention);
445 }
446
447 /** converts cached days data to stats_content format for rendering */
448 private function build_stats_content_from_cache(array $days_data, array $meta, int $window = 60): array {
449 $today = date('Y-m-d');
450 $days_data = $this->fill_missing_dates($days_data, $today, $window);
451
452 return [
453 'day_total_data' => $this->days_to_api_format($days_data),
454 'stats_tier' => $meta['stats_tier'] ?? 'basic',
455 'program_total' => $meta['program_total'] ?? 0,
456 'month_average' => $meta['month_average'] ?? 0,
457 'month_average_change' => $meta['month_average_change'] ?? 'same',
458 ];
459 }
460
461 /** transforms stats_content into chart-ready data with scale and labels */
462 private function get_chart_data(int $day_count = 7): ?array {
463 // 1. VALIDATE
464 if (empty($this->stats_content) || isset($this->stats_content['error']))
465 return null;
466
467 // 2. BUILD DAYS
468 $days = [];
469 $max_val = 0;
470 $day_total_data = $this->stats_content['day_total_data'] ?? [];
471
472 $total_entries = count($day_total_data);
473 $start_index = max(0, $total_entries - $day_count);
474 $is_month_view = $day_count > 7;
475 $comparison_offset = $is_month_view ? 30 : 7;
476
477 for ($i = $start_index; $i < $total_entries; $i++) {
478 $day_data = $day_total_data[$i] ?? [];
479 $total = (int)($day_data['trending_day_total'] ?? 0);
480 $day_date = $day_data['day_date'] ?? date('Y-m-d');
481
482 // period-over-period comparison for tooltip
483 $comparison_index = $i - $comparison_offset;
484 $comparison_total = null;
485 if ($comparison_index >= 0 && isset($day_total_data[$comparison_index]['trending_day_total'])) {
486 $comparison_total = (int)$day_total_data[$comparison_index]['trending_day_total'];
487 }
488
489 $day_entry = [
490 'total' => $total,
491 'date' => $is_month_view ? date("M d", strtotime($day_date)) : date("D d", strtotime($day_date)),
492 'full_date' => date("l M d", strtotime($day_date)),
493 ];
494
495 if ($is_month_view) {
496 $day_entry['last_month_total'] = $comparison_total;
497 } else {
498 $day_entry['last_week_total'] = $comparison_total;
499 }
500
501 $days[] = $day_entry;
502 if ($total > $max_val) $max_val = $total;
503 }
504
505 // 3. CALCULATE SCALE
506 $total_sum = array_sum(array_column($days, 'total'));
507 $display_average = count($days) > 0 ? (int)round($total_sum / count($days)) : 0;
508
509 $scale_min = 0;
510 $nice_steps = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000];
511
512 $target_max = $max_val * 1.2; // 20% headroom
513 if ($target_max < 5) $target_max = 5;
514
515 $best_step = 1;
516 $best_max = 5;
517 foreach ($nice_steps as $step) {
518 $potential_max = ceil($target_max / $step) * $step;
519 $num_steps = $potential_max / $step;
520 if ($num_steps >= 3 && $num_steps <= 6) {
521 $best_step = $step;
522 $best_max = $potential_max;
523 break;
524 } elseif ($num_steps < 3) {
525 // step too big, use previous
526 break;
527 }
528 $best_step = $step;
529 $best_max = $potential_max;
530 }
531
532 $scale_max = $best_max;
533 $scale_step = $best_step;
534
535 $scale_range = $scale_max - $scale_min;
536 if ($scale_range <= 0) $scale_range = 5;
537
538 $num_lines = (int)($scale_range / $scale_step);
539 if ($num_lines <= 0) $num_lines = 5;
540
541 // 4. BUILD LABELS
542 $scale_labels = [];
543 for ($x = 0; $x < $num_lines; $x++) {
544 $scale_labels[] = $scale_min + ($x + 1) * $scale_step;
545 }
546
547 $title_suffix = $day_count > 7 ? ' (30 Days)' : ' (7 Days)';
548 $base_title = !empty($this->stats_content['widget_title']) ? $this->stats_content['widget_title'] : 'Podcast Statistics';
549
550 // 5. RESOLVE URLS
551 $tier = $this->get_stats_tier();
552 if (!$this->has_blubrry_auth) {
553 $upgrade_url = admin_url('admin.php?page=powerpressadmin_basic&step=blubrrySignup&onboarding_type=stats');
554 } else if ($tier === 'basic') {
555 $upgrade_url = 'https://secure.blubrry.com/checkout/manage-subscriptions/';
556 } else {
557 // /s-{program_id}/ for specific show
558 $program_id = !empty($this->programs_full[$this->program_keyword]['program_id'])
559 ? $this->programs_full[$this->program_keyword]['program_id']
560 : '';
561
562 $upgrade_url = $program_id
563 ? rtrim($this->stats_base_url, '/') . '/s-' . $program_id . '/'
564 : $this->stats_base_url;
565 }
566
567 return [
568 'scale_min' => $scale_min,
569 'scale_max' => $scale_max,
570 'scale_range' => $scale_range,
571 'scale_step' => $scale_step,
572 'num_lines' => $num_lines,
573 'scale_labels' => $scale_labels,
574 'days' => $days,
575 'widget_title' => "{$base_title}{$title_suffix}",
576 'tier' => $this->get_stats_tier(),
577 'has_auth' => $this->has_blubrry_auth,
578 'upgrade_url' => $upgrade_url,
579 'display_average' => $display_average
580 ];
581 }
582
583 public function get_week_chart_data(): ?array {
584 return $this->get_chart_data(7);
585 }
586
587 public function get_month_chart_data(): ?array {
588 return $this->get_chart_data(30);
589 }
590
591 public function get_week_average(): int {
592 $chart_data = $this->get_week_chart_data();
593 return $chart_data['display_average'] ?? 0;
594 }
595
596 public function get_month_average(): int {
597 $chart_data = $this->get_month_chart_data();
598 return $chart_data['display_average'] ?? 0;
599 }
600
601 /**
602 * empty stats placeholder for new programs
603 */
604 private function get_empty_stats_content(): array {
605 $days = [];
606 for ($i = 6; $i >= 0; $i--) {
607 $date = date('Y-m-d', strtotime("-$i days"));
608 $days[] = [
609 'day_date' => $date,
610 'trending_day_total' => 0
611 ];
612 }
613
614 return [
615 'widget_title' => __('Podcast Statistics', 'powerpress'),
616 'day_total_data' => $days,
617 'scale_min' => 0,
618 'scale_max' => 5,
619 'scale_step' => 1,
620 'month_average' => 0,
621 'month_average_change' => '',
622 'program_total' => 0,
623 'is_empty' => true
624 ];
625 }
626
627 /** tier comes directly from api response, default to basic */
628 public function get_stats_tier(): string {
629 return $this->stats_content['stats_tier'] ?? 'basic';
630 }
631
632 /* ===========
633 MAIN RENDER
634 =========== */
635
636 public function render(string $new_post_query_string = ''): void {
637 ?>
638 <div class="pp-program-summary" id="pp-program-card"
639 data-feed-slug="<?php echo esc_attr($this->feed_slug); ?>"
640 data-program-keyword="<?php echo esc_attr($this->program_keyword); ?>"
641 data-default-program="<?php echo esc_attr($this->default_program_keyword); ?>">
642
643 <div class="pp-settings-program-summary">
644 <?php $this->render_stats_widget(); ?>
645
646 <div class="pp-program-row row">
647 <div class="col-md-12">
648 <?php $this->render_show_info_card(); ?>
649 </div>
650 </div>
651 </div>
652 </div>
653 <?php
654 }
655
656 /* ======================
657 STATS WIDGET RENDERING
658 ====================== */
659
660 public function render_stats_widget(bool $stacked = false): void {
661 // no widget when not connected
662 if (empty($this->stats_content) && !$this->deferred) return;
663
664 // template variables
665 $nonce = wp_create_nonce('powerpress_stats_program');
666 $ajax_url = admin_url('admin-ajax.php');
667 $chart_data = $this->deferred ? null : $this->get_week_chart_data();
668 $deferred = $this->deferred;
669 $stats_content = $this->stats_content;
670 $program_card = $this;
671
672 // load shared template
673 include(POWERPRESS_ABSPATH . '/views/stats-widget.php');
674 }
675
676 public function render_stats_header(): void {
677 $current_program_title = '';
678 if ($this->network_mode && !empty($this->programs)) {
679 $current_program_title = $this->programs[$this->program_keyword] ?? '';
680 }
681 ?>
682 <div class="pp-stats-header">
683 <div class="pp-stats-header-left">
684 <h2 class="pp-stats-title"><span class="pp-stats-title-text"><?php _e('Podcast Statistics', 'powerpress'); ?><?php echo ($this->network_mode && !empty($this->programs)) ? ':' : ''; ?></span></h2><?php if ($this->network_mode && !empty($this->programs)) : ?><button type="button" class="pp-program-selector-trigger" id="pp-program-selector-trigger" aria-haspopup="listbox" aria-expanded="false" aria-label="<?php printf(esc_attr__('Select podcast program. Currently showing: %s', 'powerpress'), esc_attr($current_program_title)); ?>">
685 <span class="pp-program-selector-name"><?php echo esc_html($current_program_title); ?></span>
686 <span class="dashicons dashicons-arrow-down-alt2" aria-hidden="true"></span>
687 </button>
688 <div class="pp-program-selector-dropdown" id="pp-program-selector-dropdown" role="listbox" aria-label="<?php esc_attr_e('Podcast programs', 'powerpress'); ?>">
689 <?php $this->render_program_selector_list(); ?>
690 </div>
691 <?php endif; ?>
692 </div>
693 <div class="pp-stats-header-controls">
694 <button type="button" class="pp-stats-btn pp-stats-cog-btn" id="pp-stats-cog-btn" data-tier="<?php echo esc_attr($this->get_stats_tier()); ?>" title="<?php esc_attr_e('Chart options', 'powerpress'); ?>" aria-label="<?php esc_attr_e('Open chart options', 'powerpress'); ?>" aria-expanded="false">
695 <span class="dashicons dashicons-admin-generic pp-cog-icon" aria-hidden="true"></span>
696 <span class="dashicons dashicons-yes-alt pp-save-icon" style="display:none;" aria-hidden="true"></span>
697 </button>
698 </div>
699 </div>
700 <?php
701 if ($this->get_stats_tier() !== 'basic') {
702 $this->render_stats_settings_row();
703 }
704 }
705
706 /**
707 * only for non-basic tier users
708 */
709 private function render_stats_settings_row(): void {
710 ?>
711 <div class="pp-stats-settings-row" id="pp-stats-settings-row" style="display:none;">
712 <div class="pp-stats-settings-line">
713 <span class="pp-stats-setting-label"><?php esc_html_e('View:', 'powerpress'); ?></span>
714 <div class="pp-stats-toggle-group">
715 <label class="pp-stats-toggle" title="<?php esc_attr_e('Show last 7 days', 'powerpress'); ?>">
716 <input type="radio" name="pp_stats_view" value="week" checked>
717 <span><?php esc_html_e('Week', 'powerpress'); ?></span>
718 </label>
719 <label class="pp-stats-toggle" title="<?php esc_attr_e('Show last 30 days', 'powerpress'); ?>">
720 <input type="radio" name="pp_stats_view" value="month">
721 <span><?php esc_html_e('Month', 'powerpress'); ?></span>
722 </label>
723 </div>
724 </div>
725 <div class="pp-stats-settings-line">
726 <span class="pp-stats-setting-label"><?php esc_html_e('Scale:', 'powerpress'); ?></span>
727 <div class="pp-stats-toggle-group">
728 <label class="pp-stats-toggle" title="<?php esc_attr_e('Standard view - equal spacing between values', 'powerpress'); ?>">
729 <input type="radio" name="pp_stats_scale" value="linear" checked>
730 <span><?php esc_html_e('Linear', 'powerpress'); ?></span>
731 </label>
732 <label class="pp-stats-toggle" title="<?php esc_attr_e('Better for viral spikes - compresses large values', 'powerpress'); ?>">
733 <input type="radio" name="pp_stats_scale" value="log">
734 <span><?php esc_html_e('Log', 'powerpress'); ?></span>
735 </label>
736 </div>
737 </div>
738 <div class="pp-stats-settings-line">
739 <span class="pp-stats-setting-label"><?php esc_html_e('Show:', 'powerpress'); ?></span>
740 <div class="pp-stats-toggle-group pp-stats-toggle-group--checkboxes">
741 <label class="pp-stats-toggle" title="<?php esc_attr_e('Growth direction over time', 'powerpress'); ?>">
742 <input type="checkbox" id="pp-stats-opt-trendline" checked>
743 <span><?php esc_html_e('Trend', 'powerpress'); ?></span>
744 </label>
745 <label class="pp-stats-toggle" title="<?php esc_attr_e('Daily average reference line', 'powerpress'); ?>">
746 <input type="checkbox" id="pp-stats-opt-avgline" checked>
747 <span><?php esc_html_e('Avg', 'powerpress'); ?></span>
748 </label>
749 <label class="pp-stats-toggle" id="pp-stats-opt-values-label" title="<?php esc_attr_e('Download counts on bars (week view only)', 'powerpress'); ?>">
750 <input type="checkbox" id="pp-stats-opt-values" checked>
751 <span><?php esc_html_e('Values', 'powerpress'); ?></span>
752 </label>
753 </div>
754 </div>
755 <div class="pp-stats-settings-line">
756 <button type="button" class="pp-stats-refresh-btn" id="pp-stats-refresh-btn" title="<?php esc_attr_e('Clear cached data and fetch fresh stats', 'powerpress'); ?>">
757 <span class="dashicons dashicons-update"></span>
758 <span><?php _e('Refresh Stats', 'powerpress'); ?></span>
759 </button>
760 </div>
761 </div>
762 <?php
763 }
764
765 public function render_stats_chart(): void {
766 // build screen reader description from current data
767 $day_total_data = $this->stats_content['day_total_data'] ?? [];
768 $num_days = count($day_total_data);
769
770 // get todays value
771 $today_data = !empty($day_total_data) ? end($day_total_data) : [];
772 $today_total = (int)($today_data['trending_day_total'] ?? 0);
773
774 // calculate average
775 $total_downloads = 0;
776 foreach ($day_total_data as $day) {
777 $total_downloads += (int)($day['trending_day_total'] ?? 0);
778 }
779 $avg_downloads = $num_days > 0 ? round($total_downloads / $num_days) : 0;
780
781 // calculate velocity (trend direction)
782 $velocity_pct = 0;
783 if ($num_days >= 2 && $avg_downloads > 0) {
784 $sum_x = 0; $sum_y = 0; $sum_xy = 0; $sum_x2 = 0;
785 $i = 0;
786 foreach ($day_total_data as $day) {
787 $val = (int)($day['trending_day_total'] ?? 0);
788 $sum_x += $i;
789 $sum_y += $val;
790 $sum_xy += $i * $val;
791 $sum_x2 += $i * $i;
792 $i++;
793 }
794 $denom = $num_days * $sum_x2 - $sum_x * $sum_x;
795 if ($denom != 0) {
796 $slope = ($num_days * $sum_xy - $sum_x * $sum_y) / $denom;
797 $velocity_pct = round(($slope / $avg_downloads) * 100 * $num_days);
798 }
799 }
800
801 // sentiment based on velocity
802 if ($velocity_pct > 5) {
803 $sentiment = __("Your podcast is growing - keep posting!", 'powerpress');
804 } elseif ($velocity_pct < -5) {
805 $sentiment = __("Downloads are slowing down. Time for fresh content!", 'powerpress');
806 } else {
807 $sentiment = __("Your podcast is holding steady.", 'powerpress');
808 }
809
810 // build description
811 $sr_parts = [];
812 if ($today_total == 0) {
813 $sr_parts[] = __("No downloads today", 'powerpress');
814 } else {
815 $sr_parts[] = sprintf(__("You had %s downloads today", 'powerpress'), number_format($today_total));
816 }
817 $sr_parts[] = sprintf(__("with a daily average of %s", 'powerpress'), number_format($avg_downloads));
818 if ($this->get_stats_tier() !== 'basic' && $velocity_pct != 0) {
819 $direction = $velocity_pct > 0 ? __('up', 'powerpress') : __('down', 'powerpress');
820 $sr_parts[] = sprintf(__("Trend is %s %d percent", 'powerpress'), $direction, abs($velocity_pct));
821 }
822 $sr_parts[] = $sentiment;
823
824 $sr_description = implode(' ', $sr_parts);
825 ?>
826 <span id="pp-stats-chart-description" class="pp-screen-reader-text"><?php echo esc_html($sr_description); ?></span>
827 <div class="pp-stats-chart" role="img" aria-label="<?php esc_attr_e('Podcast download statistics', 'powerpress'); ?>" aria-describedby="pp-stats-chart-description">
828 <canvas id="pp-stats-widget__canvas" aria-hidden="true"></canvas>
829 </div>
830 <?php
831 }
832
833 public function render_stats_summary(): void {
834 ?>
835 <div class="pp-stats-summary" id="pp-stats-summary">
836 <table class="pp-stats-summary__table" role="table" aria-label="<?php esc_attr_e('Download statistics summary', 'powerpress'); ?>">
837 <tr class="pp-stats-summary__item--first">
838 <td class="pp-stats-summary__label">Today</td>
839 <?php
840 $day_total_data = $this->stats_content['day_total_data'] ?? [];
841 $today_data = end($day_total_data) ?: [];
842 $yesterday_data = prev($day_total_data) ?: [];
843 $today_total = (int)($today_data['trending_day_total'] ?? 0);
844 $yesterday_total = (int)($yesterday_data['trending_day_total'] ?? 0);
845
846 if ($today_total < $yesterday_total) {
847 $day_img_src = powerpress_get_root_url() . 'images/down_arrow_pink.svg';
848 $day_change_text = "Decreased from yesterday";
849 } elseif ($today_total > $yesterday_total) {
850 $day_img_src = powerpress_get_root_url() . 'images/up_arrow_pink.svg';
851 $day_change_text = "Increased from yesterday";
852 } else {
853 $day_img_src = powerpress_get_root_url() . 'images/audio_lines_pink.svg';
854 $day_change_text = "Unchanged from yesterday";
855 }
856 ?>
857 <td class="pp-stats-summary__data" aria-label="<?php echo number_format($today_total) . " downloads today. $day_change_text"; ?>">
858 <?php echo number_format($today_total); ?>
859 <div class="pp-stats-summary__icon" title="<?php echo $day_change_text; ?>">
860 <img alt="today" src="<?php echo esc_url($day_img_src); ?>"/>
861 </div>
862 </td>
863 </tr>
864 <tr class="pp-stats-summary__item">
865 <td class="pp-stats-summary__label" id="pp-avg-label">7 Day Average</td>
866 <?php
867 $week_average = $this->get_week_average();
868 // icon based on week-over-week trend (compare to previous 7 days)
869 $week_change = $this->stats_content['month_average_change'] ?? '';
870 switch ($week_change) {
871 case 'up':
872 $avg_img_src = powerpress_get_root_url() . 'images/up_arrow.svg';
873 $avg_change_text = "Increased from last week";
874 break;
875 case 'down':
876 $avg_img_src = powerpress_get_root_url() . 'images/down_arrow.svg';
877 $avg_change_text = "Decreased from last week";
878 break;
879 default:
880 $avg_img_src = powerpress_get_root_url() . 'images/audio_lines.svg';
881 $avg_change_text = "Unchanged from last week";
882 break;
883 }
884 ?>
885 <td class="pp-stats-summary__data" id="pp-avg-value" aria-label="<?php echo number_format($week_average) . " average downloads for the past week. $avg_change_text"; ?>">
886 <?php echo number_format($week_average); ?>
887 <div class="pp-stats-summary__icon" title="<?php echo $avg_change_text; ?>">
888 <img alt="average change" src="<?php echo esc_url($avg_img_src); ?>"/>
889 </div>
890 </td>
891 </tr>
892 <tr class="pp-stats-summary__item">
893 <td class="pp-stats-summary__label">Total Downloads</td>
894 <?php
895 if ($today_total > 0) {
896 $total_img_src = powerpress_get_root_url() . 'images/up_arrow.svg';
897 $total_change_text = "Increased from yesterday";
898 } else {
899 $total_img_src = powerpress_get_root_url() . 'images/audio_lines.svg';
900 $total_change_text = "Unchanged from yesterday";
901 }
902 $program_total = $this->stats_content['program_total'] ?? 0;
903 ?>
904 <td class="pp-stats-summary__data" aria-label="<?php echo number_format($program_total) . " total downloads for program. $total_change_text"; ?>">
905 <?php echo number_format($program_total); ?>
906 <div class="pp-stats-summary__icon" title="<?php echo $total_change_text; ?>">
907 <img alt="total" src="<?php echo esc_url($total_img_src); ?>"/>
908 </div>
909 </td>
910 </tr>
911 </table>
912 <?php if (!empty($this->stats_content['is_empty'])) : ?>
913 <p class="pp-stats-empty-message">
914 <?php _e('Your podcast is just getting started! Downloads will appear here once listeners tune in.', 'powerpress'); ?>
915 </p>
916 <?php endif; ?>
917 <?php
918 $program_id = !empty($this->programs_full[$this->program_keyword]['program_id'])
919 ? $this->programs_full[$this->program_keyword]['program_id']
920 : '';
921 $stats_link = $program_id
922 ? rtrim($this->stats_base_url, '/') . '/s-' . $program_id . '/'
923 : $this->stats_base_url;
924 ?>
925 <div class="pp-stats-footer-actions">
926 <a class="pp-stats-btn pp-stats-btn--primary" href="<?php echo esc_url(admin_url('post-new.php')); ?>">
927 <?php esc_html_e('New Episode', 'powerpress'); ?>
928 </a>
929 <a id="pp-stats-advanced-link" class="pp-stats-btn pp-stats-btn--secondary" href="<?php echo esc_url($stats_link); ?>" target="_blank">
930 <?php esc_html_e('See all statistics', 'powerpress'); ?>
931 </a>
932 </div>
933 </div>
934 <?php
935 }
936
937
938 /** splits programs into with/without stats for selector ordering */
939 private function get_sorted_programs(): array {
940 $no_stats_programs = get_transient('powerpress_no_stats_programs');
941 if (!is_array($no_stats_programs)) $no_stats_programs = [];
942
943 $with_stats = [];
944 $without_stats = [];
945 foreach ($this->programs as $keyword => $title) {
946 if (isset($no_stats_programs[$keyword])) {
947 $without_stats[$keyword] = $title;
948 } else {
949 $with_stats[$keyword] = $title;
950 }
951 }
952
953 ksort($with_stats);
954 ksort($without_stats);
955
956 return ['with_stats' => $with_stats, 'without_stats' => $without_stats];
957 }
958
959 /** renders <select> dropdown for program switching */
960 private function render_program_selector_options(): void {
961 $sorted = $this->get_sorted_programs();
962 ?>
963 <select id="pp-stats-program-select" name="pp_stats_program">
964 <?php foreach ($sorted['with_stats'] as $keyword => $title) : ?>
965 <option value="<?php echo esc_attr($keyword); ?>"<?php echo ((string)$this->program_keyword === (string)$keyword) ? ' selected' : ''; ?>><?php echo esc_html($title); ?></option>
966 <?php endforeach; ?>
967 <?php foreach ($sorted['without_stats'] as $keyword => $title) : ?>
968 <option value="<?php esc_attr_e($keyword); ?>" disabled><?php echo esc_html($title); ?> (<?php _e('stats not enabled', 'powerpress'); ?>)</option>
969 <?php endforeach; ?>
970 </select>
971 <?php
972 }
973
974 /** renders accessible listbox for inline program selector */
975 private function render_program_selector_list(): void {
976 $sorted = $this->get_sorted_programs();
977 ?>
978 <ul class="pp-program-selector-list" role="presentation">
979 <?php foreach ($sorted['with_stats'] as $keyword => $title) :
980 $is_selected = ((string)$this->program_keyword === (string)$keyword);
981 ?>
982 <li role="option" aria-selected="<?php echo $is_selected ? 'true' : 'false'; ?>">
983 <button type="button" class="pp-program-selector-item<?php echo $is_selected ? ' pp-program-selector-item--active' : ''; ?>" data-keyword="<?php echo esc_attr($keyword); ?>">
984 <?php echo esc_html($title); ?>
985 </button>
986 </li>
987 <?php endforeach; ?>
988 <?php if (!empty($sorted['without_stats'])) : ?>
989 <li class="pp-program-selector-divider" role="separator" aria-hidden="true"></li>
990 <?php foreach ($sorted['without_stats'] as $keyword => $title) : ?>
991 <li role="option" aria-disabled="true" aria-selected="false">
992 <button type="button" class="pp-program-selector-item pp-program-selector-item--disabled" disabled aria-label="<?php printf(esc_attr__('%s - stats not enabled', 'powerpress'), esc_attr($title)); ?>">
993 <?php echo esc_html($title); ?>
994 <span class="pp-program-selector-item-note"><?php _e('stats not enabled', 'powerpress'); ?></span>
995 </button>
996 </li>
997 <?php endforeach; ?>
998 <?php endif; ?>
999 </ul>
1000 <?php
1001 }
1002
1003 /* ========================
1004 SHOW INFO CARD RENDERING
1005 ======================== */
1006
1007 private function render_show_info_card(): void {
1008 $podcast_title = $this->program_info['title'] ?: __('Podcast', 'powerpress');
1009 $episode_count = intval($this->program_info['episode_count']);
1010 $img_alt = sprintf(__('Cover art for %s', 'powerpress'), $podcast_title);
1011 ?>
1012 <div class="prog-sum-head" id="pp-show-info-card" role="region" aria-labelledby="welcome-title">
1013 <div class="pp-program-card">
1014 <div class="pp-program-card__header">
1015 <h2 class="pp-heading" id="welcome-title"><?php echo esc_html($podcast_title); ?></h2>
1016 <div class="pp-program-card__episode-count" aria-label="<?php printf(esc_attr__('%d episodes published', 'powerpress'), $episode_count); ?>">
1017 <span class="pp-program-card__label" aria-hidden="true"><?php _e('Episodes', 'powerpress'); ?>:</span>
1018 <span class="pp-episode-count" aria-hidden="true"><?php echo $episode_count; ?></span>
1019 </div>
1020 </div>
1021 <div class="pp-program-card__body">
1022 <img id="welcome-preview-image" src="<?php echo esc_url($this->program_info['image']); ?>" alt="<?php echo esc_attr($img_alt); ?>" onerror="this.onerror=null; this.src='<?php echo esc_url(powerpress_get_root_url() . 'images/pts_cover.jpg'); ?>';" />
1023 <div class="pp-program-card__content">
1024 <p class="pp-program-card__author"><span class="pp-program-card__label"><?php _e('By', 'powerpress'); ?></span> <?php echo esc_html($this->program_info['author']); ?></p>
1025
1026 <div class="pp-program-card__main">
1027 <p class="pp-program-card__description"><span class="pp-program-card__label"><?php _e('Description', 'powerpress'); ?>:</span> <?php echo esc_html($this->program_info['description']); ?></p>
1028 </div>
1029
1030 <div class="pp-program-card__footer">
1031 <p class="pp-program-card__category"<?php echo empty($this->program_info['category']) ? ' style="display: none;"' : ''; ?>><span class="pp-program-card__label"><?php _e('Category', 'powerpress'); ?>:</span> <?php echo esc_html($this->program_info['category']); ?></p>
1032 <div class="pp-program-card__dates">
1033 <span class="pp-program-card__last-update"<?php echo empty($this->program_info['last_update']) ? ' style="display: none;"' : ''; ?>><span class="pp-program-card__label"><?php _e('Last Upload', 'powerpress'); ?>:</span> <?php echo esc_html($this->format_date($this->program_info['last_update'])); ?></span>
1034 <span class="pp-program-card__date-separator" aria-hidden="true"<?php echo (empty($this->program_info['last_update']) || empty($this->program_info['created_date'])) ? ' style="display: none;"' : ''; ?>>|</span>
1035 <span class="pp-program-card__created-date"<?php echo empty($this->program_info['created_date']) ? ' style="display: none;"' : ''; ?>><span class="pp-program-card__label"><?php _e('Started Publishing', 'powerpress'); ?>:</span> <?php echo esc_html($this->format_date($this->program_info['created_date'])); ?></span>
1036 </div>
1037 </div>
1038 </div>
1039 </div>
1040 </div>
1041 </div>
1042 <?php
1043 }
1044
1045 /** checks if url matches any known default/placeholder artwork */
1046 private function is_placeholder_image(string $url): bool {
1047 if (empty($url)) return true;
1048 // api default, pp grey logo, pp blue logo
1049 return strpos($url, 'default.jpg') !== false
1050 || strpos($url, 'pts_cover.jpg') !== false
1051 || strpos($url, 'itunes_default.jpg') !== false;
1052 }
1053
1054 /** formats unix timestamp or date string using WP date format */
1055 private function format_date($timestamp): string {
1056 if (empty($timestamp)) return '';
1057 if (is_numeric($timestamp))
1058 return date_i18n(get_option('date_format'), $timestamp);
1059 $ts = strtotime($timestamp);
1060 if ($ts === false || $ts < 0) return '';
1061 return date_i18n(get_option('date_format'), $ts);
1062 }
1063
1064 /* =====================
1065 AJAX RESPONSE METHODS
1066 ===================== */
1067
1068 public function get_stats_widget_html(bool $stacked = false): string {
1069 ob_start();
1070 $this->render_stats_widget($stacked);
1071 return ob_get_clean();
1072 }
1073
1074 public function get_show_info_card_html(): string {
1075 ob_start();
1076 $this->render_show_info_card();
1077 return ob_get_clean();
1078 }
1079
1080 public function get_program_info_data(): array {
1081 return $this->program_info;
1082 }
1083
1084 /* =========
1085 ACCESSORS
1086 ========= */
1087
1088 public function is_network_mode(): bool { return $this->network_mode; }
1089 public function get_programs(): array { return $this->programs; }
1090 public function get_current_program_keyword(): string { return $this->program_keyword; }
1091
1092 /* ===================
1093 DELTA CACHE HELPERS
1094 =================== */
1095
1096 /** determines how many days to fetch based on cache gap */
1097 private function calculate_stats_delta(array $cached_days, string $today, int $max_days): array {
1098 if (empty($cached_days)) {
1099 return ['fetch_days' => $max_days, 'is_full_fetch' => true];
1100 }
1101
1102 $last_date = max(array_keys($cached_days));
1103 $last_ts = strtotime($last_date);
1104 $today_ts = strtotime($today);
1105
1106 $gap_days = (int)(($today_ts - $last_ts) / 86400);
1107
1108 // same day = still fetch 1 day for today refresh
1109 if ($gap_days <= 0) {
1110 return ['fetch_days' => 1, 'is_full_fetch' => false];
1111 }
1112
1113 // gap exceeds retention = full fetch
1114 if ($gap_days >= $max_days) {
1115 return ['fetch_days' => $max_days, 'is_full_fetch' => true];
1116 }
1117
1118 return ['fetch_days' => $gap_days, 'is_full_fetch' => false];
1119 }
1120
1121 /**
1122 * merge daily data from data API format (date/downloads keys)
1123 */
1124 private function merge_stats_days_from_data_api(array $existing, array $new_data): array {
1125 foreach ($new_data as $day) {
1126 if (isset($day['date']) && isset($day['downloads'])) {
1127 $existing[$day['date']] = (int)$day['downloads'];
1128 }
1129 }
1130 return $existing;
1131 }
1132
1133 /** drops dates older than retention window */
1134 private function prune_stats_days(array $days_data, string $today, int $retention_days): array {
1135 $cutoff_ts = strtotime($today) - ($retention_days * 86400);
1136
1137 return array_filter($days_data, function($date) use ($cutoff_ts) {
1138 return strtotime($date) >= $cutoff_ts;
1139 }, ARRAY_FILTER_USE_KEY);
1140 }
1141
1142 /**
1143 * fills gaps with zeros to prevent unnecessary refetches
1144 */
1145 private function fill_missing_dates(array $days_data, string $today, int $window): array {
1146 $today_ts = strtotime($today);
1147 for ($i = 0; $i < $window; $i++) {
1148 $date = date('Y-m-d', $today_ts - ($i * 86400));
1149 if (!isset($days_data[$date])) {
1150 $days_data[$date] = 0;
1151 }
1152 }
1153 return $days_data;
1154 }
1155
1156 /** converts date=>total map to [{day_date, trending_day_total}] */
1157 private function days_to_api_format(array $days_data): array {
1158 ksort($days_data);
1159 $result = [];
1160 foreach ($days_data as $date => $total) {
1161 $result[] = ['day_date' => $date, 'trending_day_total' => $total];
1162 }
1163 return $result;
1164 }
1165
1166 /** validate YYYY-MM-DD format */
1167 private function is_valid_cache_date($date): bool {
1168 if (!is_string($date) || empty($date)) {
1169 return false;
1170 }
1171 return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1;
1172 }
1173
1174 /**
1175 * clears legacy cache formats (v1/v2/v3)
1176 */
1177 private function migrate_stats_cache(array $stats_cached, string $keyword): array {
1178 $modified = false;
1179
1180 // clear old v1 format (simple updated/content keys)
1181 if (isset($stats_cached['updated']) || isset($stats_cached['content'])) {
1182 unset($stats_cached['updated'], $stats_cached['content'], $stats_cached['retry_count']);
1183 $modified = true;
1184 }
1185
1186 // clear old v2/v3 format keys
1187 foreach (array_keys($stats_cached) as $key) {
1188 if (preg_match('/_v[23]$/', $key)) {
1189 unset($stats_cached[$key]);
1190 $modified = true;
1191 }
1192 }
1193
1194 if ($modified) {
1195 update_option('powerpress_stats', $stats_cached);
1196 }
1197
1198 return $stats_cached;
1199 }
1200 }
1201