Files
MokoSuiteCross/source/packages/com_mokosuitecross/src/Helper/AnalyticsHelper.php
T
jmiller d6848e6b90
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
fix: resolve 10 critical/medium bugs from deep dive audit
- deleteFromPlatforms(): use CredentialHelper::decrypt() + Joomla 6
  dispatcher pattern instead of json_decode + deprecated triggerEvent (#226, #228)
- PostsController: add ACL checks on retryFailed/purgePosted (#224)
- QueueProcessor: recover stale posting entries stuck >10min (#235)
- onContentChangeState: respect post_on_first_publish_only (#238)
- Uninstall SQL: add analytics + category_rules table drops (#225)
- Dashboard/Calendar: remove deprecated Sidebar::render() (#250)
- AnalyticsHelper: rewrite AJAX endpoints to query posts table (#246)
- Submenu helper: remove duplicate calendar key (#248)
- CHANGELOG: remove 3 duplicate version headers (#240)

Authored-by: Moko Consulting

Claude-Session: https://claude.ai/code/session_014iwLv3vUVsSxP8LyZ6STTj
2026-06-29 11:27:48 -05:00

251 lines
9.0 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
{
/**
* Record or update engagement metrics for a post.
*/
public static function recordEngagement(int $postId, int $serviceId, string $serviceType, array $metrics): bool
{
$db = Factory::getDbo();
$postedAt = $metrics['posted_at'] ?? null;
if ($postedAt) {
$timestamp = strtotime($postedAt);
$dayOfWeek = (int) date('w', $timestamp);
$hourOfDay = (int) date('G', $timestamp);
} else {
$dayOfWeek = 0;
$hourOfDay = 0;
}
$impressions = (int) ($metrics['impressions'] ?? 0);
$engagements = (int) ($metrics['engagements'] ?? 0);
$clicks = (int) ($metrics['clicks'] ?? 0);
$shares = (int) ($metrics['shares'] ?? 0);
$engagementRate = $impressions > 0
? round(($engagements / $impressions) * 100, 2)
: 0.00;
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokosuitecross_analytics'))
->where($db->quoteName('post_id') . ' = ' . $postId)
->where($db->quoteName('service_id') . ' = ' . $serviceId);
$db->setQuery($query);
$existingId = $db->loadResult();
if ($existingId) {
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitecross_analytics'))
->set($db->quoteName('impressions') . ' = ' . $impressions)
->set($db->quoteName('engagements') . ' = ' . $engagements)
->set($db->quoteName('clicks') . ' = ' . $clicks)
->set($db->quoteName('shares') . ' = ' . $shares)
->set($db->quoteName('engagement_rate') . ' = ' . $engagementRate)
->where($db->quoteName('id') . ' = ' . (int) $existingId);
$db->setQuery($query);
$db->execute();
return true;
}
$record = (object) [
'post_id' => $postId,
'service_id' => $serviceId,
'service_type' => $serviceType,
'posted_at' => $postedAt,
'day_of_week' => $dayOfWeek,
'hour_of_day' => $hourOfDay,
'impressions' => $impressions,
'engagements' => $engagements,
'clicks' => $clicks,
'shares' => $shares,
'engagement_rate' => $engagementRate,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitecross_analytics', $record);
return true;
}
/**
* Get heatmap data as a 7x24 grid derived from actual post success data.
*/
public static function getHeatmapData(string $serviceType = '', int $days = 90): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([
'DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS day_of_week',
'HOUR(' . $db->quoteName('p.posted_at') . ') AS hour_of_day',
'COUNT(*) AS post_count',
])
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's')
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
->where($db->quoteName('p.status') . ' = ' . $db->quote('posted'))
->where($db->quoteName('p.posted_at') . ' IS NOT NULL')
->group('day_of_week')
->group('hour_of_day')
->order('day_of_week ASC')
->order('hour_of_day ASC');
if ($serviceType !== '') {
$query->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType));
}
if ($days > 0) {
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
$query->where($db->quoteName('p.posted_at') . ' >= ' . $db->quote($cutoff));
}
$db->setQuery($query);
$rows = $db->loadObjectList();
$maxCount = 1;
foreach ($rows as $row) {
if ((int) $row->post_count > $maxCount) {
$maxCount = (int) $row->post_count;
}
}
$grid = [];
for ($d = 0; $d < 7; $d++) {
for ($h = 0; $h < 24; $h++) {
$grid[$d][$h] = ['avg_rate' => 0.00, 'post_count' => 0];
}
}
foreach ($rows as $row) {
$count = (int) $row->post_count;
$grid[(int) $row->day_of_week][(int) $row->hour_of_day] = [
'avg_rate' => round(($count / $maxCount) * 100, 2),
'post_count' => $count,
];
}
return $grid;
}
/**
* Get the best times to post ranked by post success frequency.
*/
public static function getBestTimes(string $serviceType = '', int $limit = 5): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([
'DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS day_of_week',
'HOUR(' . $db->quoteName('p.posted_at') . ') AS hour_of_day',
'COUNT(*) AS post_count',
])
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's')
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
->where($db->quoteName('p.status') . ' = ' . $db->quote('posted'))
->where($db->quoteName('p.posted_at') . ' IS NOT NULL')
->group('day_of_week')
->group('hour_of_day')
->having('COUNT(*) >= 1')
->order('post_count DESC');
if ($serviceType !== '') {
$query->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType));
}
$db->setQuery($query, 0, $limit);
$rows = $db->loadAssocList();
$dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
$results = [];
foreach ($rows as $row) {
$hour = (int) $row['hour_of_day'];
$ampm = $hour < 12 ? 'AM' : 'PM';
$hour12 = $hour % 12 ?: 12;
$results[] = [
'day_of_week' => (int) $row['day_of_week'],
'day_name' => $dayNames[(int) $row['day_of_week']],
'hour_of_day' => $hour,
'hour_label' => $hour12 . ':00 ' . $ampm,
'avg_rate' => round((float) $row['post_count'], 2),
'post_count' => (int) $row['post_count'],
];
}
return $results;
}
/**
* Get stats grouped by service type from actual post data.
*/
public static function getServiceBreakdown(int $days = 30): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([
$db->quoteName('s.service_type'),
'COUNT(*) AS total_posts',
'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS total_succeeded',
'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' IN ('
. $db->quote('failed') . ',' . $db->quote('permanently_failed')
. ') THEN 1 ELSE 0 END) AS total_failed',
])
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's')
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
->group($db->quoteName('s.service_type'))
->order('total_posts DESC');
if ($days > 0) {
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
$query->where($db->quoteName('p.created') . ' >= ' . $db->quote($cutoff));
}
$db->setQuery($query);
$rows = $db->loadAssocList();
foreach ($rows as &$row) {
$total = (int) $row['total_posts'];
$succeeded = (int) $row['total_succeeded'];
$row['total_posts'] = $total;
$row['total_succeeded'] = $succeeded;
$row['total_failed'] = (int) $row['total_failed'];
$row['avg_engagement_rate'] = $total > 0 ? round(($succeeded / $total) * 100, 2) : 0;
$row['total_impressions'] = 0;
$row['total_engagements'] = 0;
$row['total_clicks'] = 0;
$row['total_shares'] = 0;
}
return $rows;
}
}