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']); ?>) |
+ |
+ |
+ |
+
+
+ %
+
+ |
+ |
+
+
+
+
+
+
+
+