diff --git a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini index f4ce7f59..eb318bd0 100644 --- a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini +++ b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini @@ -614,6 +614,14 @@ COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE="No data" COM_MOKOSUITECROSS_PERIOD_180_DAYS="Last 180 days" COM_MOKOSUITECROSS_PERIOD_365_DAYS="Last 365 days" + +; Analytics +COM_MOKOSUITECROSS_ANALYTICS="Analytics" +COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post" +COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap" +COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough data yet. Analytics will appear after posts collect engagement metrics." +COM_MOKOSUITECROSS_ANALYTICS_ENGAGEMENT_RATE="Engagement Rate" +COM_MOKOSUITECROSS_ANALYTICS_ALL_PLATFORMS="All Platforms" ; Category Rules COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules" COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing" diff --git a/source/packages/com_mokosuitecross/sql/install.mysql.sql b/source/packages/com_mokosuitecross/sql/install.mysql.sql index 0a788f58..e40c8a08 100644 --- a/source/packages/com_mokosuitecross/sql/install.mysql.sql +++ b/source/packages/com_mokosuitecross/sql/install.mysql.sql @@ -96,6 +96,27 @@ INSERT INTO `#__mokosuitecross_templates` (`service_type`, `title`, `template_bo ('instagram', 'Instagram Default', '{social}\n\n{hashtags}', 1, 21, NOW()), ('youtube', 'YouTube Default', '{social}\n\n{url}', 1, 22, NOW()); + +CREATE TABLE IF NOT EXISTS `#__mokosuitecross_analytics` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `post_id` int unsigned NOT NULL, + `service_id` int unsigned NOT NULL, + `service_type` varchar(50) NOT NULL DEFAULT '', + `posted_at` datetime DEFAULT NULL, + `day_of_week` tinyint unsigned NOT NULL DEFAULT 0, + `hour_of_day` tinyint unsigned NOT NULL DEFAULT 0, + `impressions` int unsigned NOT NULL DEFAULT 0, + `engagements` int unsigned NOT NULL DEFAULT 0, + `clicks` int unsigned NOT NULL DEFAULT 0, + `shares` int unsigned NOT NULL DEFAULT 0, + `engagement_rate` decimal(5,2) NOT NULL DEFAULT 0.00, + `created` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_service_type` (`service_type`), + KEY `idx_day_hour` (`day_of_week`, `hour_of_day`), + KEY `idx_post` (`post_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `#__mokosuitecross_category_rules` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `category_id` int(10) unsigned NOT NULL, diff --git a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.54.sql b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.54.sql index ac182a97..8cba2fa6 100644 --- a/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.54.sql +++ b/source/packages/com_mokosuitecross/sql/updates/mysql/01.08.54.sql @@ -1 +1,23 @@ -/* 01.08.54 — no schema changes */ +-- MokoSuiteCross 01.08.54 -- Best time to post analytics +-- Copyright (C) 2026 Moko Consulting. All rights reserved. +-- SPDX-License-Identifier: GPL-3.0-or-later + +CREATE TABLE IF NOT EXISTS `#__mokosuitecross_analytics` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `post_id` int unsigned NOT NULL, + `service_id` int unsigned NOT NULL, + `service_type` varchar(50) NOT NULL DEFAULT '', + `posted_at` datetime DEFAULT NULL, + `day_of_week` tinyint unsigned NOT NULL DEFAULT 0, + `hour_of_day` tinyint unsigned NOT NULL DEFAULT 0, + `impressions` int unsigned NOT NULL DEFAULT 0, + `engagements` int unsigned NOT NULL DEFAULT 0, + `clicks` int unsigned NOT NULL DEFAULT 0, + `shares` int unsigned NOT NULL DEFAULT 0, + `engagement_rate` decimal(5,2) NOT NULL DEFAULT 0.00, + `created` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_service_type` (`service_type`), + KEY `idx_day_hour` (`day_of_week`, `hour_of_day`), + KEY `idx_post` (`post_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php b/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php index fce9f59b..d7eb2e1e 100644 --- a/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php +++ b/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php @@ -14,11 +14,84 @@ namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; defined('_JEXEC') or die; use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Session\Session; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\AnalyticsHelper; class AnalyticsController extends BaseController { - public function display($cachable = false, $urlparams = []): static + /** + * Return heatmap grid data as JSON. + * + * Query params: service_type (string), days (int, default 90) + */ + public function heatmap(): void { - return parent::display($cachable, $urlparams); + if (!Session::checkToken('get')) { + echo json_encode(['success' => false, 'error' => 'Invalid token']); + $this->app->close(); + + return; + } + + $user = $this->app->getIdentity(); + + if (!$user->authorise('core.manage', 'com_mokosuitecross')) { + echo json_encode(['success' => false, 'error' => 'Permission denied']); + $this->app->close(); + + return; + } + + $serviceType = $this->input->getCmd('service_type', ''); + $days = $this->input->getInt('days', 90); + + $grid = AnalyticsHelper::getHeatmapData($serviceType, $days); + $bestTimes = AnalyticsHelper::getBestTimes($serviceType, 3); + + $this->app->setHeader('Content-Type', 'application/json; charset=utf-8'); + echo json_encode([ + 'success' => true, + 'grid' => $grid, + 'best_times' => $bestTimes, + ]); + $this->app->close(); + } + + /** + * Return the top posting times as JSON. + * + * Query params: service_type (string), limit (int, default 5) + */ + public function besttimes(): void + { + if (!Session::checkToken('get')) { + echo json_encode(['success' => false, 'error' => 'Invalid token']); + $this->app->close(); + + return; + } + + $user = $this->app->getIdentity(); + + if (!$user->authorise('core.manage', 'com_mokosuitecross')) { + echo json_encode(['success' => false, 'error' => 'Permission denied']); + $this->app->close(); + + return; + } + + $serviceType = $this->input->getCmd('service_type', ''); + $limit = $this->input->getInt('limit', 5); + + $bestTimes = AnalyticsHelper::getBestTimes($serviceType, $limit); + $serviceBreakdown = AnalyticsHelper::getServiceBreakdown(); + + $this->app->setHeader('Content-Type', 'application/json; charset=utf-8'); + echo json_encode([ + 'success' => true, + 'best_times' => $bestTimes, + 'service_breakdown' => $serviceBreakdown, + ]); + $this->app->close(); } } diff --git a/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php b/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php index a2b324ff..47e917a9 100644 --- a/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php +++ b/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php @@ -17,144 +17,236 @@ use Joomla\CMS\Factory; class AnalyticsHelper { - private static array $dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + /** + * Record or update engagement metrics for a post. + * + * @param int $postId The post ID + * @param int $serviceId The service ID + * @param string $serviceType The service type (e.g. twitter, facebook) + * @param array $metrics Engagement metrics: impressions, engagements, clicks, shares, posted_at + * + * @return bool True on success + */ + public static function recordEngagement(int $postId, int $serviceId, string $serviceType, array $metrics): bool + { + $db = Factory::getDbo(); - public static function getPostingHeatmap(string $serviceType = '', int $days = 90): array + $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; + + // Check if a row already exists for this post + $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 of average engagement rates. + * + * @param string $serviceType Optional service type filter + * @param int $days Number of days to look back (0 = all time) + * + * @return array 7x24 grid: [ day_of_week => [ hour_of_day => avg_engagement_rate ] ] + */ + 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 dow') - ->select('HOUR(' . $db->quoteName('p.posted_at') . ') AS hr') - ->select('COUNT(*) AS cnt') - ->from($db->quoteName('#__mokosuitecross_posts', 'p')) - ->where($db->quoteName('p.status') . ' = ' . $db->quote('posted')) - ->where($db->quoteName('p.posted_at') . ' IS NOT NULL'); - - if ($days > 0) { - $since = Factory::getDate('now - ' . $days . ' days')->toSql(); - $query->where($db->quoteName('p.posted_at') . ' >= ' . $db->quote($since)); - } + ->select([ + $db->quoteName('day_of_week'), + $db->quoteName('hour_of_day'), + 'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_rate', + 'COUNT(*) AS post_count', + ]) + ->from($db->quoteName('#__mokosuitecross_analytics')) + ->group($db->quoteName('day_of_week')) + ->group($db->quoteName('hour_of_day')) + ->order($db->quoteName('day_of_week') . ' ASC') + ->order($db->quoteName('hour_of_day') . ' ASC'); if ($serviceType !== '') { - $query->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) - ->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType)); + $query->where($db->quoteName('service_type') . ' = ' . $db->quote($serviceType)); } - $query->group('dow, hr') - ->order('dow ASC, hr ASC'); + if ($days > 0) { + $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); + $query->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff)); + } $db->setQuery($query); $rows = $db->loadObjectList(); + // Build 7x24 grid initialised to zero $grid = []; for ($d = 0; $d < 7; $d++) { - $grid[$d] = array_fill(0, 24, 0); + for ($h = 0; $h < 24; $h++) { + $grid[$d][$h] = ['avg_rate' => 0.00, 'post_count' => 0]; + } } foreach ($rows as $row) { - $grid[(int) $row->dow][(int) $row->hr] = (int) $row->cnt; + $grid[(int) $row->day_of_week][(int) $row->hour_of_day] = [ + 'avg_rate' => round((float) $row->avg_rate, 2), + 'post_count' => (int) $row->post_count, + ]; } return $grid; } - public static function getBestTimes(string $serviceType = '', int $days = 90, int $limit = 5): array + /** + * Get the best times to post ranked by average engagement rate. + * + * @param string $serviceType Optional service type filter + * @param int $limit Number of results to return + * + * @return array List of [day_of_week, hour_of_day, avg_rate, post_count] + */ + public static function getBestTimes(string $serviceType = '', int $limit = 5): array { - $grid = self::getPostingHeatmap($serviceType, $days); - $slots = []; + $db = Factory::getDbo(); - foreach ($grid as $dow => $hours) { - foreach ($hours as $hour => $count) { - if ($count > 0) { - $slots[] = [ - 'day' => self::$dayNames[$dow], - 'hour' => $hour, - 'count' => $count, - 'label' => self::$dayNames[$dow] . ' ' . self::formatHour($hour), - ]; - } - } + $query = $db->getQuery(true) + ->select([ + $db->quoteName('day_of_week'), + $db->quoteName('hour_of_day'), + 'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_rate', + 'COUNT(*) AS post_count', + ]) + ->from($db->quoteName('#__mokosuitecross_analytics')) + ->group($db->quoteName('day_of_week')) + ->group($db->quoteName('hour_of_day')) + ->having('COUNT(*) >= 1') + ->order('avg_rate DESC'); + + if ($serviceType !== '') { + $query->where($db->quoteName('service_type') . ' = ' . $db->quote($serviceType)); } - usort($slots, static fn($a, $b) => $b['count'] <=> $a['count']); + $db->setQuery($query, 0, $limit); + $rows = $db->loadAssocList(); - return \array_slice($slots, 0, $limit); + $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['avg_rate'], 2), + 'post_count' => (int) $row['post_count'], + ]; + } + + return $results; } + /** + * Get engagement stats grouped by service type. + * + * @param int $days Number of days to look back (0 = all time) + * + * @return array List of [service_type, total_posts, avg_engagement_rate, total_impressions, total_engagements] + */ public static function getServiceBreakdown(int $days = 30): array { $db = Factory::getDbo(); $query = $db->getQuery(true) - ->select($db->quoteName('s.service_type')) - ->select($db->quoteName('s.title', 'service_title')) - ->select('COUNT(*) AS total') - ->select('SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success') - ->select('SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed') - ->from($db->quoteName('#__mokosuitecross_posts', 'p')) - ->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')); + ->select([ + $db->quoteName('service_type'), + 'COUNT(*) AS total_posts', + 'AVG(' . $db->quoteName('engagement_rate') . ') AS avg_engagement_rate', + 'SUM(' . $db->quoteName('impressions') . ') AS total_impressions', + 'SUM(' . $db->quoteName('engagements') . ') AS total_engagements', + 'SUM(' . $db->quoteName('clicks') . ') AS total_clicks', + 'SUM(' . $db->quoteName('shares') . ') AS total_shares', + ]) + ->from($db->quoteName('#__mokosuitecross_analytics')) + ->group($db->quoteName('service_type')) + ->order('avg_engagement_rate DESC'); if ($days > 0) { - $since = Factory::getDate('now - ' . $days . ' days')->toSql(); - $query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since)); + $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); + $query->where($db->quoteName('posted_at') . ' >= ' . $db->quote($cutoff)); } - $query->group($db->quoteName(['s.service_type', 's.title'])) - ->order('total DESC'); - $db->setQuery($query); - $rows = $db->loadObjectList(); + $rows = $db->loadAssocList(); - $result = []; - - foreach ($rows as $row) { - $total = (int) $row->total; - $success = (int) $row->success; - $result[] = [ - 'service_type' => $row->service_type, - 'service_title' => $row->service_title, - 'total' => $total, - 'success' => $success, - 'failed' => (int) $row->failed, - 'success_rate' => $total > 0 ? round(($success / $total) * 100, 1) : 0.0, - 'avg_per_day' => $days > 0 ? round($total / $days, 1) : 0.0, - ]; + foreach ($rows as &$row) { + $row['avg_engagement_rate'] = round((float) $row['avg_engagement_rate'], 2); + $row['total_posts'] = (int) $row['total_posts']; + $row['total_impressions'] = (int) $row['total_impressions']; + $row['total_engagements'] = (int) $row['total_engagements']; + $row['total_clicks'] = (int) $row['total_clicks']; + $row['total_shares'] = (int) $row['total_shares']; } - return $result; + return $rows; } - - public static function getServiceTypes(): array - { - $db = Factory::getDbo(); - - $query = $db->getQuery(true) - ->select('DISTINCT ' . $db->quoteName('service_type')) - ->from($db->quoteName('#__mokosuitecross_services')) - ->where($db->quoteName('published') . ' = 1') - ->order($db->quoteName('service_type') . ' ASC'); - - $db->setQuery($query); - - return $db->loadColumn() ?: []; - } - - private static function formatHour(int $hour): string - { - if ($hour === 0) { - return '12:00 AM'; - } - - if ($hour < 12) { - return $hour . ':00 AM'; - } - - if ($hour === 12) { - return '12:00 PM'; - } - - return ($hour - 12) . ':00 PM'; - } -} +} \ No newline at end of file diff --git a/source/packages/com_mokosuitecross/tmpl/dashboard/default.php b/source/packages/com_mokosuitecross/tmpl/dashboard/default.php index 4ac74e7c..42eb5ff5 100644 --- a/source/packages/com_mokosuitecross/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuitecross/tmpl/dashboard/default.php @@ -220,6 +220,175 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler'); + +