Nextcloud Analytics Hub complete: - Nextcloud PHP app (analytics-hub/) - All phases (1-3) complete - Go client tool (nextcloud-analytics) - Full CLI implementation - Documentation (PRD, README, STATUS, SKILL.md) - Production-ready for deployment to https://cloud.shortcutsolutions.net Repository: git.teamworkapps.com/shortcut/nextcloud-analytics Workspace: /home/molt/.openclaw/workspace
255 lines
7.9 KiB
PHP
255 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\AnalyticsHub\Service;
|
|
|
|
use OCA\AnalyticsHub\Model\ClientConfig;
|
|
use OCP\Util\Logger;
|
|
|
|
/**
|
|
* Data Processor Service
|
|
* Handles intelligent delta calculations and data validation
|
|
*/
|
|
class DataProcessor {
|
|
|
|
private ?Logger $logger;
|
|
|
|
// Default thresholds
|
|
private const DEFAULT_SIGNIFICANT_CHANGE_PCT = 20;
|
|
private const DEFAULT_SIGNIFICANT_CHANGE_ABS = 5;
|
|
|
|
public function __construct() {
|
|
$this->logger = \OC::$server->getLogger();
|
|
}
|
|
|
|
/**
|
|
* Process and validate GA4 data
|
|
*/
|
|
public function process(array $rawData, ClientConfig $client): array {
|
|
$this->logger->info("Processing data for: {$client->getName()}");
|
|
|
|
try {
|
|
// Validate data completeness
|
|
$this->validateCompleteness($rawData);
|
|
|
|
// Calculate deltas
|
|
$processed = $this->calculateDeltas($rawData);
|
|
|
|
// Apply client thresholds
|
|
$processed = $this->applyThresholds($processed, $client);
|
|
|
|
// Generate summary
|
|
$processed['summary'] = $this->generateSummary($processed);
|
|
|
|
return $processed;
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error("Data processing failed: {$e->getMessage()}");
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate data completeness
|
|
*/
|
|
private function validateCompleteness(array $data): void {
|
|
$requiredKeys = ['dates', 'metrics'];
|
|
|
|
foreach ($requiredKeys as $key) {
|
|
if (!isset($data[$key])) {
|
|
throw new DataIncompleteException("Missing required key: {$key}");
|
|
}
|
|
}
|
|
|
|
// Check we have at least 7 days of data
|
|
if (!isset($data['dates']) || count($data['dates']) < 7) {
|
|
throw new DataIncompleteException("Insufficient data - need at least 7 days");
|
|
}
|
|
|
|
// Check for null or empty metrics
|
|
$metrics = $data['metrics'] ?? [];
|
|
$requiredMetrics = ['sessions', 'totalUsers', 'conversions', 'eventCount'];
|
|
|
|
foreach ($requiredMetrics as $metric) {
|
|
if (!isset($metrics[$metric])) {
|
|
throw new DataIncompleteException("Missing required metric: {$metric}");
|
|
}
|
|
|
|
$values = $metrics[$metric] ?? [];
|
|
foreach ($values as $value) {
|
|
if ($value === null || $value === '') {
|
|
throw new DataIncompleteException("Null or empty value in metric: {$metric}");
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->logger->info('Data validation passed');
|
|
}
|
|
|
|
/**
|
|
* Calculate intelligent deltas
|
|
*/
|
|
private function calculateDeltas(array $data): array {
|
|
$metrics = $data['metrics'] ?? [];
|
|
$processed = [];
|
|
|
|
foreach ($metrics as $metric => $values) {
|
|
$processed[$metric] = $this->calculateMetricDelta($values);
|
|
}
|
|
|
|
$processed['dates'] = $data['dates'] ?? [];
|
|
|
|
return $processed;
|
|
}
|
|
|
|
/**
|
|
* Calculate delta for a single metric
|
|
*/
|
|
private function calculateMetricDelta(array $values): array {
|
|
if (count($values) < 2) {
|
|
return [
|
|
'change' => 0,
|
|
'change_pct' => 0,
|
|
'abs_change' => 0,
|
|
'is_significant' => false,
|
|
'label' => 'Insufficient data',
|
|
'trend' => 'flat'
|
|
];
|
|
}
|
|
|
|
$current = $values[count($values) - 1]; // Last 7 days
|
|
$previous = $values[0]; // First 7 days (comparison)
|
|
|
|
// Edge case 1: Division by zero
|
|
if ($previous == 0) {
|
|
if ($current == 0) {
|
|
return [
|
|
'change' => 0,
|
|
'change_pct' => 0,
|
|
'abs_change' => 0,
|
|
'is_significant' => false,
|
|
'label' => 'No change',
|
|
'trend' => 'stable'
|
|
];
|
|
} else {
|
|
return [
|
|
'change' => $current,
|
|
'change_pct' => null,
|
|
'abs_change' => $current,
|
|
'is_significant' => true,
|
|
'label' => "New activity (+{$current})",
|
|
'trend' => 'increasing'
|
|
];
|
|
}
|
|
}
|
|
|
|
// Calculate percentage change
|
|
$changePct = (($current - $previous) / $previous) * 100;
|
|
$absChange = abs($current - $previous);
|
|
|
|
// Edge case 2: Small numbers creating misleading %
|
|
// Require both % threshold AND minimum absolute change
|
|
$thresholds = [
|
|
'significant_change_pct' => self::DEFAULT_SIGNIFICANT_CHANGE_PCT,
|
|
'significant_change_abs' => self::DEFAULT_SIGNIFICANT_CHANGE_ABS
|
|
];
|
|
|
|
$isSignificant = (abs($changePct) > $thresholds['significant_change_pct']
|
|
&& $absChange > $thresholds['significant_change_abs']);
|
|
|
|
// Determine trend
|
|
$trend = 'stable';
|
|
if ($absChange > $thresholds['significant_change_abs']) {
|
|
$trend = $changePct > 0 ? 'increasing' : 'decreasing';
|
|
}
|
|
|
|
return [
|
|
'current' => $current,
|
|
'previous' => $previous,
|
|
'change' => $current - $previous,
|
|
'change_pct' => round($changePct, 1),
|
|
'abs_change' => $absChange,
|
|
'is_significant' => $isSignificant,
|
|
'label' => $this->formatDeltaLabel($changePct, $absChange, $isSignificant),
|
|
'trend' => $trend
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Format delta label
|
|
*/
|
|
private function formatDeltaLabel(float $changePct, float $absChange, bool $isSignificant): string {
|
|
if (!$isSignificant) {
|
|
return 'No significant change';
|
|
}
|
|
|
|
$direction = $changePct > 0 ? '+' : '';
|
|
$pct = abs($changePct);
|
|
$change = (int)$absChange;
|
|
|
|
return "{$direction}{$pct}% ({$direction}{$change})";
|
|
}
|
|
|
|
/**
|
|
* Apply client-specific thresholds
|
|
*/
|
|
private function applyThresholds(array $processed, ClientConfig $client): array {
|
|
$thresholds = $client->getThresholds();
|
|
|
|
if (!$thresholds) {
|
|
// Use defaults
|
|
$processed['significant_change_pct'] = self::DEFAULT_SIGNIFICANT_CHANGE_PCT;
|
|
$processed['significant_change_abs'] = self::DEFAULT_SIGNIFICANT_CHANGE_ABS;
|
|
} else {
|
|
// Use client thresholds
|
|
$processed['significant_change_pct'] = $thresholds['significant_change_pct'] ?? self::DEFAULT_SIGNIFICANT_CHANGE_PCT;
|
|
$processed['significant_change_abs'] = $thresholds['significant_change_abs'] ?? self::DEFAULT_SIGNIFICANT_CHANGE_ABS;
|
|
}
|
|
|
|
// Re-evaluate significance based on thresholds
|
|
foreach ($processed as $metric => $data) {
|
|
if (is_array($data)) {
|
|
$data['is_significant'] = (
|
|
abs($data['change_pct'] ?? 0) > $processed['significant_change_pct']
|
|
&& abs($data['abs_change'] ?? 0) > $processed['significant_change_abs']
|
|
);
|
|
}
|
|
}
|
|
|
|
return $processed;
|
|
}
|
|
|
|
/**
|
|
* Generate summary
|
|
*/
|
|
private function generateSummary(array $processed): array {
|
|
$totalMetrics = 0;
|
|
$significantChanges = 0;
|
|
$increasing = 0;
|
|
$decreasing = 0;
|
|
|
|
foreach ($processed as $metric => $data) {
|
|
if (is_array($data)) {
|
|
$totalMetrics++;
|
|
if ($data['is_significant'] ?? false) {
|
|
$significantChanges++;
|
|
}
|
|
if (($data['trend'] ?? '') === 'increasing') {
|
|
$increasing++;
|
|
}
|
|
if (($data['trend'] ?? '') === 'decreasing') {
|
|
$decreasing++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'total_metrics' => $totalMetrics,
|
|
'significant_changes' => $significantChanges,
|
|
'increasing' => $increasing,
|
|
'decreasing' => $decreasing
|
|
];
|
|
}
|
|
}
|