* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * SPDX-License-Identifier: GPL-3.0-or-later */ namespace Joomla\Component\MokoSuiteCross\Administrator\Helper; defined('_JEXEC') or die; use Joomla\CMS\Factory; class AnalyticsHelper { /** * Record or update engagement metrics for a post. */ public static function recordEngagement(int $postId, int $serviceId, string $serviceType, array $metrics): bool { $db = Factory::getDbo(); $postedAt = $metrics['posted_at'] ?? null; if ($postedAt) { $timestamp = strtotime($postedAt); $dayOfWeek = (int) date('w', $timestamp); $hourOfDay = (int) date('G', $timestamp); } else { $dayOfWeek = 0; $hourOfDay = 0; } $impressions = (int) ($metrics['impressions'] ?? 0); $engagements = (int) ($metrics['engagements'] ?? 0); $clicks = (int) ($metrics['clicks'] ?? 0); $shares = (int) ($metrics['shares'] ?? 0); $engagementRate = $impressions > 0 ? round(($engagements / $impressions) * 100, 2) : 0.00; $query = $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName('#__mokosuitecross_analytics')) ->where($db->quoteName('post_id') . ' = ' . $postId) ->where($db->quoteName('service_id') . ' = ' . $serviceId); $db->setQuery($query); $existingId = $db->loadResult(); if ($existingId) { $query = $db->getQuery(true) ->update($db->quoteName('#__mokosuitecross_analytics')) ->set($db->quoteName('impressions') . ' = ' . $impressions) ->set($db->quoteName('engagements') . ' = ' . $engagements) ->set($db->quoteName('clicks') . ' = ' . $clicks) ->set($db->quoteName('shares') . ' = ' . $shares) ->set($db->quoteName('engagement_rate') . ' = ' . $engagementRate) ->where($db->quoteName('id') . ' = ' . (int) $existingId); $db->setQuery($query); $db->execute(); return true; } $record = (object) [ 'post_id' => $postId, 'service_id' => $serviceId, 'service_type' => $serviceType, 'posted_at' => $postedAt, 'day_of_week' => $dayOfWeek, 'hour_of_day' => $hourOfDay, 'impressions' => $impressions, 'engagements' => $engagements, 'clicks' => $clicks, 'shares' => $shares, 'engagement_rate' => $engagementRate, 'created' => Factory::getDate()->toSql(), ]; $db->insertObject('#__mokosuitecross_analytics', $record); return true; } /** * Get heatmap data as a 7x24 grid derived from actual post success data. */ public static function getHeatmapData(string $serviceType = '', int $days = 90): array { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select([ 'DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS day_of_week', 'HOUR(' . $db->quoteName('p.posted_at') . ') AS hour_of_day', 'COUNT(*) AS post_count', ]) ->from($db->quoteName('#__mokosuitecross_posts', 'p')) ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) ->where($db->quoteName('p.status') . ' = ' . $db->quote('posted')) ->where($db->quoteName('p.posted_at') . ' IS NOT NULL') ->group('day_of_week') ->group('hour_of_day') ->order('day_of_week ASC') ->order('hour_of_day ASC'); if ($serviceType !== '') { $query->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType)); } if ($days > 0) { $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); $query->where($db->quoteName('p.posted_at') . ' >= ' . $db->quote($cutoff)); } $db->setQuery($query); $rows = $db->loadObjectList(); $maxCount = 1; foreach ($rows as $row) { if ((int) $row->post_count > $maxCount) { $maxCount = (int) $row->post_count; } } $grid = []; for ($d = 0; $d < 7; $d++) { for ($h = 0; $h < 24; $h++) { $grid[$d][$h] = ['avg_rate' => 0.00, 'post_count' => 0]; } } foreach ($rows as $row) { $count = (int) $row->post_count; $grid[(int) $row->day_of_week][(int) $row->hour_of_day] = [ 'avg_rate' => round(($count / $maxCount) * 100, 2), 'post_count' => $count, ]; } return $grid; } /** * Get the best times to post ranked by post success frequency. */ public static function getBestTimes(string $serviceType = '', int $limit = 5): array { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select([ 'DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS day_of_week', 'HOUR(' . $db->quoteName('p.posted_at') . ') AS hour_of_day', 'COUNT(*) AS post_count', ]) ->from($db->quoteName('#__mokosuitecross_posts', 'p')) ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) ->where($db->quoteName('p.status') . ' = ' . $db->quote('posted')) ->where($db->quoteName('p.posted_at') . ' IS NOT NULL') ->group('day_of_week') ->group('hour_of_day') ->having('COUNT(*) >= 1') ->order('post_count DESC'); if ($serviceType !== '') { $query->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType)); } $db->setQuery($query, 0, $limit); $rows = $db->loadAssocList(); $dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; $results = []; foreach ($rows as $row) { $hour = (int) $row['hour_of_day']; $ampm = $hour < 12 ? 'AM' : 'PM'; $hour12 = $hour % 12 ?: 12; $results[] = [ 'day_of_week' => (int) $row['day_of_week'], 'day_name' => $dayNames[(int) $row['day_of_week']], 'hour_of_day' => $hour, 'hour_label' => $hour12 . ':00 ' . $ampm, 'avg_rate' => round((float) $row['post_count'], 2), 'post_count' => (int) $row['post_count'], ]; } return $results; } /** * Get stats grouped by service type from actual post data. */ public static function getServiceBreakdown(int $days = 30): array { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select([ $db->quoteName('s.service_type'), 'COUNT(*) AS total_posts', 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS total_succeeded', 'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' IN (' . $db->quote('failed') . ',' . $db->quote('permanently_failed') . ') THEN 1 ELSE 0 END) AS total_failed', ]) ->from($db->quoteName('#__mokosuitecross_posts', 'p')) ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) ->group($db->quoteName('s.service_type')) ->order('total_posts DESC'); if ($days > 0) { $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); $query->where($db->quoteName('p.created') . ' >= ' . $db->quote($cutoff)); } $db->setQuery($query); $rows = $db->loadAssocList(); foreach ($rows as &$row) { $total = (int) $row['total_posts']; $succeeded = (int) $row['total_succeeded']; $row['total_posts'] = $total; $row['total_succeeded'] = $succeeded; $row['total_failed'] = (int) $row['total_failed']; $row['avg_engagement_rate'] = $total > 0 ? round(($succeeded / $total) * 100, 2) : 0; $row['total_impressions'] = 0; $row['total_engagements'] = 0; $row['total_clicks'] = 0; $row['total_shares'] = 0; } return $rows; } }