From 80d01f6da3cc12d00c28019a2d8dc8ff604db184 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 11:51:04 -0500 Subject: [PATCH] feat(#165): add posting analytics with best-time heatmap - AnalyticsHelper: posting heatmap (7x24 grid), best times ranking, per-service breakdown with success rates - AnalyticsController: AJAX endpoint for dynamic heatmap filtering - Analytics HtmlView: toolbar, dashboard link, submenu integration - Template: heatmap table with color intensity, best times cards, service breakdown table, service/period filters - 16 new language strings for analytics UI Authored-by: Moko Consulting Closes #165 --- CHANGELOG.md | 4 + .../language/en-GB/com_mokosuitecross.ini | 18 ++ .../src/Controller/AnalyticsController.php | 52 ++++++ .../src/Helper/AnalyticsHelper.php | 160 +++++++++++++++++ .../src/View/Analytics/HtmlView.php | 63 +++++++ .../tmpl/analytics/default.php | 169 ++++++++++++++++++ 6 files changed, 466 insertions(+) create mode 100644 source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php create mode 100644 source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php create mode 100644 source/packages/com_mokosuitecross/src/View/Analytics/HtmlView.php create mode 100644 source/packages/com_mokosuitecross/tmpl/analytics/default.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de93027..20ce633d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] ### Added +- **Posting analytics**: Best time to post heatmap with day-of-week and hour-of-day breakdown (#165) +- **Analytics service filter**: Filter heatmap and stats by service type with configurable date range +- **Analytics service breakdown**: Per-service success rate, failure count, and average posts per day +- **Analytics AJAX endpoint**: JSON heatmap data for dynamic filtering without page reload - **Social image generator**: Generate Open Graph images with article title overlay using PHP GD library (#157) - **Social image config**: Background color, text color, overlay style, and site name override in component options (#157) - **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161) 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 524392a0..fb5327ec 100644 --- a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini +++ b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini @@ -590,3 +590,21 @@ COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules" COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing" COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE_DESC="Category routing rules let you map Joomla categories to specific cross-post services. When rules exist for a category, only those services receive posts. When no rules exist, all services are used (default behaviour). Rules are managed in the database table #__mokosuitecross_category_rules. A full admin UI will be added in a future release." + +; Posting Analytics +COM_MOKOSUITECROSS_ANALYTICS_FILTER_SERVICE="Service" +COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES="All Services" +COM_MOKOSUITECROSS_ANALYTICS_PERIOD="Period" +COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post" +COM_MOKOSUITECROSS_ANALYTICS_POSTS_COUNT="%d posts" +COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Posting Heatmap" +COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="No posting data available for the selected period." +COM_MOKOSUITECROSS_ANALYTICS_LESS="Less" +COM_MOKOSUITECROSS_ANALYTICS_MORE="More" +COM_MOKOSUITECROSS_ANALYTICS_SERVICE_BREAKDOWN="Service Breakdown" +COM_MOKOSUITECROSS_ANALYTICS_SERVICE="Service" +COM_MOKOSUITECROSS_ANALYTICS_TOTAL="Total" +COM_MOKOSUITECROSS_ANALYTICS_SUCCESS="Success" +COM_MOKOSUITECROSS_ANALYTICS_FAILED="Failed" +COM_MOKOSUITECROSS_ANALYTICS_SUCCESS_RATE="Success Rate" +COM_MOKOSUITECROSS_ANALYTICS_AVG_PER_DAY="Avg/Day" diff --git a/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php b/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php new file mode 100644 index 00000000..9189243b --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/AnalyticsController.php @@ -0,0 +1,52 @@ + + * @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\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 getHeatmapData(): void + { + if (!Session::checkToken('get')) { + echo json_encode(['success' => false, 'error' => 'Invalid token']); + $this->app->close(); + + return; + } + + if (!$this->app->getIdentity()->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); + + $heatmap = AnalyticsHelper::getPostingHeatmap($serviceType, $days); + $bestTimes = AnalyticsHelper::getBestTimes($serviceType, $days); + + $this->app->setHeader('Content-Type', 'application/json; charset=utf-8'); + echo json_encode([ + 'success' => true, + 'heatmap' => $heatmap, + 'best_times' => $bestTimes, + ]); + $this->app->close(); + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php b/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php new file mode 100644 index 00000000..a2b324ff --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php @@ -0,0 +1,160 @@ + + * @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 +{ + private static array $dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + public static function getPostingHeatmap(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)); + } + + 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->group('dow, hr') + ->order('dow ASC, hr ASC'); + + $db->setQuery($query); + $rows = $db->loadObjectList(); + + $grid = []; + + for ($d = 0; $d < 7; $d++) { + $grid[$d] = array_fill(0, 24, 0); + } + + foreach ($rows as $row) { + $grid[(int) $row->dow][(int) $row->hr] = (int) $row->cnt; + } + + return $grid; + } + + public static function getBestTimes(string $serviceType = '', int $days = 90, int $limit = 5): array + { + $grid = self::getPostingHeatmap($serviceType, $days); + $slots = []; + + 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), + ]; + } + } + } + + usort($slots, static fn($a, $b) => $b['count'] <=> $a['count']); + + return \array_slice($slots, 0, $limit); + } + + 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')); + + if ($days > 0) { + $since = Factory::getDate('now - ' . $days . ' days')->toSql(); + $query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since)); + } + + $query->group($db->quoteName(['s.service_type', 's.title'])) + ->order('total DESC'); + + $db->setQuery($query); + $rows = $db->loadObjectList(); + + $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, + ]; + } + + return $result; + } + + 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'; + } +} diff --git a/source/packages/com_mokosuitecross/src/View/Analytics/HtmlView.php b/source/packages/com_mokosuitecross/src/View/Analytics/HtmlView.php new file mode 100644 index 00000000..f2742736 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/View/Analytics/HtmlView.php @@ -0,0 +1,63 @@ + + * @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\View\Analytics; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\Toolbar; +use Joomla\CMS\Toolbar\ToolbarHelper; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\AnalyticsHelper; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper; + +class HtmlView extends BaseHtmlView +{ + public array $heatmap = []; + public array $bestTimes = []; + public array $serviceBreakdown = []; + public array $serviceTypes = []; + public string $serviceFilter = ''; + public int $days = 90; + + public function display($tpl = null): void + { + $input = Factory::getApplication()->input; + + $this->serviceFilter = $input->getCmd('service_type', ''); + $this->days = $input->getInt('days', 90); + $this->heatmap = AnalyticsHelper::getPostingHeatmap($this->serviceFilter, $this->days); + $this->bestTimes = AnalyticsHelper::getBestTimes($this->serviceFilter, $this->days); + $this->serviceBreakdown = AnalyticsHelper::getServiceBreakdown($this->days); + $this->serviceTypes = AnalyticsHelper::getServiceTypes(); + + $this->addToolbar(); + + MokoSuiteCrossHelper::addSubmenu('analytics'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title('MokoSuiteCross -- Posting Analytics', 'chart'); + + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->appendButton( + 'Link', + 'home', + 'COM_MOKOSUITECROSS_SUBMENU_DASHBOARD', + Route::_('index.php?option=com_mokosuitecross&view=dashboard', false) + ); + } +} diff --git a/source/packages/com_mokosuitecross/tmpl/analytics/default.php b/source/packages/com_mokosuitecross/tmpl/analytics/default.php new file mode 100644 index 00000000..5484fcbd --- /dev/null +++ b/source/packages/com_mokosuitecross/tmpl/analytics/default.php @@ -0,0 +1,169 @@ + + * @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 + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; + +/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Analytics\HtmlView $this */ + +$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +$maxCount = 0; +foreach ($this->heatmap as $hours) { + $dayMax = max($hours); + if ($dayMax > $maxCount) { + $maxCount = $dayMax; + } +} +?> + +
+ + +
+
+ + +
+
+ + +
+
+
+ +bestTimes)) : ?> +
+
+

+
+
+
+ bestTimes as $i => $slot) : ?> +
+
+
#
+
escape($slot['label']); ?>
+
+
+
+ +
+
+
+ + +
+
+

+
+
+ +
+ +
+ + + + + + + + + + + $dayName) : ?> + + + + heatmap[$d][$h]; + $intensity = $maxCount > 0 ? $count / $maxCount : 0; + $bg = ''; + + if ($count > 0) { + $alpha = 0.15 + ($intensity * 0.85); + $bg = 'background-color: rgba(13, 110, 253, ' . round($alpha, 2) . '); color: ' . ($alpha > 0.6 ? '#fff' : '#000') . ';'; + } + ?> + + + + + +
+ +
+
+
+ + + + + + +
+ +
+
+ +serviceBreakdown)) : ?> +
+
+

+
+
+
+ + + + + + + + + + + + + serviceBreakdown as $row) : ?> + + + + + + + + + + +
escape($row['service_title']); ?> (escape($row['service_type']); ?>) + + % + +
+
+
+
+