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
- 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
161 lines
5.3 KiB
PHP
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';
|
|
}
|
|
}
|