feat(#165): add posting analytics with best-time heatmap
Universal: Auto Version Bump / Version Bump (push) Successful in 7s

- 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
This commit is contained in:
2026-06-28 11:51:04 -05:00
parent 0c2074f801
commit 80d01f6da3
6 changed files with 466 additions and 0 deletions
+4
View File
@@ -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)
@@ -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"
@@ -0,0 +1,52 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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();
}
}
@@ -0,0 +1,160 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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';
}
}
@@ -0,0 +1,63 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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)
);
}
}
@@ -0,0 +1,169 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
}
}
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitecross&view=analytics'); ?>" method="get" class="mb-4">
<input type="hidden" name="option" value="com_mokosuitecross">
<input type="hidden" name="view" value="analytics">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label" for="filter_service_type"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_FILTER_SERVICE'); ?></label>
<select id="filter_service_type" name="service_type" class="form-select" onchange="this.form.submit();">
<option value=""><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES'); ?></option>
<?php foreach ($this->serviceTypes as $type) : ?>
<option value="<?php echo $this->escape($type); ?>"<?php if ($this->serviceFilter === $type) echo ' selected'; ?>>
<?php echo $this->escape(ucfirst($type)); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label" for="filter_days"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_PERIOD'); ?></label>
<select id="filter_days" name="days" class="form-select" onchange="this.form.submit();">
<?php foreach ([30 => '30 days', 60 => '60 days', 90 => '90 days', 180 => '180 days', 365 => '1 year'] as $val => $label) : ?>
<option value="<?php echo $val; ?>"<?php if ($this->days === $val) echo ' selected'; ?>><?php echo $label; ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</form>
<?php if (!empty($this->bestTimes)) : ?>
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES'); ?></h3>
</div>
<div class="card-body">
<div class="row">
<?php foreach ($this->bestTimes as $i => $slot) : ?>
<div class="col-sm-6 col-md-4 col-lg mb-2">
<div class="border rounded p-3 text-center">
<div class="fw-bold text-primary">#<?php echo $i + 1; ?></div>
<div class="fs-5 fw-semibold"><?php echo $this->escape($slot['label']); ?></div>
<div class="text-muted"><?php echo Text::sprintf('COM_MOKOSUITECROSS_ANALYTICS_POSTS_COUNT', $slot['count']); ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_HEATMAP'); ?></h3>
</div>
<div class="card-body">
<?php if ($maxCount === 0) : ?>
<div class="alert alert-info"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_NO_DATA'); ?></div>
<?php else : ?>
<div class="table-responsive">
<table class="table table-bordered table-sm text-center" style="font-size: 0.8rem;">
<thead>
<tr>
<th></th>
<?php for ($h = 0; $h < 24; $h++) : ?>
<th class="px-1"><?php echo $h; ?></th>
<?php endfor; ?>
</tr>
</thead>
<tbody>
<?php foreach ($dayNames as $d => $dayName) : ?>
<tr>
<th class="text-nowrap"><?php echo $dayName; ?></th>
<?php for ($h = 0; $h < 24; $h++) : ?>
<?php
$count = $this->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') . ';';
}
?>
<td style="<?php echo $bg; ?>" title="<?php echo $dayName . ' ' . $h . ':00 - ' . $count . ' posts'; ?>">
<?php echo $count ?: ''; ?>
</td>
<?php endfor; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="d-flex align-items-center mt-2 small text-muted">
<span class="me-2"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LESS'); ?></span>
<?php for ($i = 0; $i <= 4; $i++) : ?>
<?php $alpha = $i === 0 ? 0 : 0.15 + ($i / 4) * 0.85; ?>
<span style="display:inline-block;width:16px;height:16px;border:1px solid #dee2e6;<?php echo $i > 0 ? 'background-color:rgba(13,110,253,' . round($alpha, 2) . ')' : ''; ?>" class="me-1"></span>
<?php endfor; ?>
<span class="ms-1"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_MORE'); ?></span>
</div>
<?php endif; ?>
</div>
</div>
<?php if (!empty($this->serviceBreakdown)) : ?>
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_SERVICE_BREAKDOWN'); ?></h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_SERVICE'); ?></th>
<th class="text-center"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_TOTAL'); ?></th>
<th class="text-center"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_SUCCESS'); ?></th>
<th class="text-center"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_FAILED'); ?></th>
<th class="text-center"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_SUCCESS_RATE'); ?></th>
<th class="text-center"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_AVG_PER_DAY'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->serviceBreakdown as $row) : ?>
<tr>
<td><?php echo $this->escape($row['service_title']); ?> <span class="text-muted">(<?php echo $this->escape($row['service_type']); ?>)</span></td>
<td class="text-center"><?php echo $row['total']; ?></td>
<td class="text-center text-success"><?php echo $row['success']; ?></td>
<td class="text-center text-danger"><?php echo $row['failed']; ?></td>
<td class="text-center">
<span class="badge <?php echo $row['success_rate'] >= 90 ? 'bg-success' : ($row['success_rate'] >= 70 ? 'bg-warning' : 'bg-danger'); ?>">
<?php echo $row['success_rate']; ?>%
</span>
</td>
<td class="text-center"><?php echo $row['avg_per_day']; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>