Files
MokoSuiteCross/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php
T
jmiller 9c2dd1bdde
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 36s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
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
2026-06-28 11:57:02 -05:00

161 lines
5.3 KiB
PHP

<?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';
}
}