d6848e6b90
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
- 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
251 lines
9.0 KiB
PHP
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;
|
|
}
|
|
}
|