Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
353
src/Application/Analytics/AnalyticsController.php
Normal file
353
src/Application/Analytics/AnalyticsController.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics;
|
||||
|
||||
use App\Application\Analytics\Service\AnalyticsDashboardService;
|
||||
use App\Application\Analytics\Service\AnalyticsRealTimeService;
|
||||
use App\Application\Analytics\Service\AnalyticsReportService;
|
||||
use App\Framework\Analytics\AnalyticsCategory;
|
||||
use App\Framework\Analytics\AnalyticsCollector;
|
||||
use App\Framework\Analytics\Storage\AnalyticsStorage;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Meta\StaticPageMetaResolver;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
final class AnalyticsController
|
||||
{
|
||||
public function __construct(
|
||||
private AnalyticsCollector $analyticsCollector,
|
||||
private AnalyticsStorage $storage,
|
||||
private AnalyticsDashboardService $dashboardService,
|
||||
private AnalyticsReportService $reportService,
|
||||
private AnalyticsRealTimeService $realTimeService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics', method: Method::GET)]
|
||||
public function dashboard(): ViewResult
|
||||
{
|
||||
return new ViewResult(
|
||||
'analytics-dashboard',
|
||||
new StaticPageMetaResolver(
|
||||
'Analytics Dashboard',
|
||||
'View website analytics and user behavior data'
|
||||
)(),
|
||||
[
|
||||
'title' => 'Analytics Dashboard',
|
||||
'description' => 'Monitor your website performance and user engagement',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/overview', method: Method::GET)]
|
||||
public function getOverview(): JsonResult
|
||||
{
|
||||
try {
|
||||
$endDate = $_GET['end_date'] ?? date('Y-m-d');
|
||||
$startDate = $_GET['start_date'] ?? date('Y-m-d', strtotime('-30 days'));
|
||||
|
||||
// Calculate days between dates
|
||||
$days = max(1, (strtotime($endDate) - strtotime($startDate)) / 86400 + 1);
|
||||
|
||||
$overview = $this->dashboardService->getOverview($startDate, $endDate);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $overview,
|
||||
'period' => [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
'days' => (int)$days,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/timeseries', method: Method::GET)]
|
||||
public function getTimeSeries(): JsonResult
|
||||
{
|
||||
try {
|
||||
$metric = $_GET['metric'] ?? 'page_views';
|
||||
$period = $_GET['period'] ?? 'day';
|
||||
$days = (int) ($_GET['days'] ?? 30);
|
||||
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime("-{$days} days"));
|
||||
|
||||
$timeSeries = $this->storage->getTimeSeries($metric, $startDate, $endDate, $period);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $timeSeries,
|
||||
'metric' => $metric,
|
||||
'period' => $period,
|
||||
'range' => [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
'days' => $days,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/top-pages', method: Method::GET)]
|
||||
public function getTopPages(): JsonResult
|
||||
{
|
||||
try {
|
||||
$limit = (int) ($_GET['limit'] ?? 10);
|
||||
$days = (int) ($_GET['days'] ?? 30);
|
||||
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime("-{$days} days"));
|
||||
|
||||
$topPages = $this->dashboardService->getTopPages($startDate, $endDate, $limit);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $topPages,
|
||||
'limit' => $limit,
|
||||
'period' => [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
'days' => $days,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/traffic-sources', method: Method::GET)]
|
||||
public function getTrafficSources(): JsonResult
|
||||
{
|
||||
try {
|
||||
$days = (int) ($_GET['days'] ?? 30);
|
||||
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime("-{$days} days"));
|
||||
|
||||
$sources = $this->dashboardService->getTrafficSources($startDate, $endDate);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $sources,
|
||||
'period' => [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
'days' => $days,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/user-behavior', method: Method::GET)]
|
||||
public function getUserBehavior(): JsonResult
|
||||
{
|
||||
try {
|
||||
$days = (int) ($_GET['days'] ?? 30);
|
||||
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime("-{$days} days"));
|
||||
|
||||
$behavior = $this->reportService->getUserBehavior($startDate, $endDate);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $behavior,
|
||||
'period' => [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
'days' => $days,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/business-metrics', method: Method::GET)]
|
||||
public function getBusinessMetrics(): JsonResult
|
||||
{
|
||||
try {
|
||||
$days = (int) ($_GET['days'] ?? 30);
|
||||
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime("-{$days} days"));
|
||||
|
||||
$metrics = $this->reportService->getBusinessMetrics($startDate, $endDate);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $metrics,
|
||||
'period' => [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
'days' => $days,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/real-time', method: Method::GET)]
|
||||
public function getRealTimeData(): JsonResult
|
||||
{
|
||||
try {
|
||||
$realTime = $this->realTimeService->getRealTimeData();
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $realTime,
|
||||
'timestamp' => time(),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/events', method: Method::GET)]
|
||||
public function getEvents(): JsonResult
|
||||
{
|
||||
try {
|
||||
$category = $_GET['category'] ?? null;
|
||||
$limit = (int) ($_GET['limit'] ?? 100);
|
||||
$offset = (int) ($_GET['offset'] ?? 0);
|
||||
$days = (int) ($_GET['days'] ?? 7);
|
||||
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime("-{$days} days"));
|
||||
|
||||
$categoryFilter = $category ? AnalyticsCategory::tryFrom($category) : null;
|
||||
|
||||
$events = $this->reportService->getEvents($startDate, $endDate, $categoryFilter, $limit, $offset);
|
||||
$total = $this->reportService->getEventsCount($startDate, $endDate, $categoryFilter);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $events,
|
||||
'pagination' => [
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'total' => $total,
|
||||
'has_more' => ($offset + $limit) < $total,
|
||||
],
|
||||
'filter' => [
|
||||
'category' => $category,
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/export', method: Method::GET)]
|
||||
public function exportData(): JsonResult
|
||||
{
|
||||
try {
|
||||
$format = $_GET['format'] ?? 'json';
|
||||
$category = $_GET['category'] ?? null;
|
||||
$days = (int) ($_GET['days'] ?? 30);
|
||||
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime("-{$days} days"));
|
||||
|
||||
$categoryFilter = $category ? AnalyticsCategory::tryFrom($category) : null;
|
||||
|
||||
$data = $this->reportService->exportData($startDate, $endDate, $categoryFilter, $format);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'format' => $format,
|
||||
'exported_at' => date('Y-m-d H:i:s'),
|
||||
'period' => [
|
||||
'start' => $startDate,
|
||||
'end' => $endDate,
|
||||
'days' => $days,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/analytics/api/track', method: Method::POST)]
|
||||
public function trackEvent(): JsonResult
|
||||
{
|
||||
try {
|
||||
$action = $_POST['action'] ?? null;
|
||||
$category = $_POST['category'] ?? 'user_behavior';
|
||||
$properties = json_decode($_POST['properties'] ?? '{}', true);
|
||||
|
||||
if (! $action) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => 'Action is required',
|
||||
], Status::from(400));
|
||||
}
|
||||
|
||||
$categoryEnum = AnalyticsCategory::tryFrom($category);
|
||||
if (! $categoryEnum) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => 'Invalid category',
|
||||
], Status::from(400));
|
||||
}
|
||||
|
||||
$this->analyticsCollector->trackAction($action, $categoryEnum, $properties);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'message' => 'Event tracked successfully',
|
||||
'tracked_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], Status::from(500));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\Contracts;
|
||||
|
||||
/**
|
||||
* Interface for Value Objects that can be converted to legacy array format
|
||||
*/
|
||||
interface LegacyArrayConvertible
|
||||
{
|
||||
/**
|
||||
* Convert to legacy array format for backward compatibility
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array;
|
||||
|
||||
/**
|
||||
* Convert to enhanced analytics array format
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toAnalyticsArray(): array;
|
||||
}
|
||||
110
src/Application/Analytics/Service/AnalyticsDashboardService.php
Normal file
110
src/Application/Analytics/Service/AnalyticsDashboardService.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\Service;
|
||||
|
||||
use App\Framework\Analytics\Storage\AnalyticsStorage;
|
||||
|
||||
/**
|
||||
* Service für Analytics Dashboard-Daten
|
||||
* Verantwortlich für: Zusammenfassung und Aufbereitung von Dashboard-Metriken
|
||||
*/
|
||||
final readonly class AnalyticsDashboardService
|
||||
{
|
||||
public function __construct(
|
||||
private AnalyticsStorage $storage
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
public function getOverview(string $startDate, string $endDate): array
|
||||
{
|
||||
$timeSeries = $this->storage->getTimeSeries('page_views_total', $startDate, $endDate, 'hour');
|
||||
$aggregatedData = $this->storage->getAggregated($startDate, $endDate, 'hour');
|
||||
|
||||
$totalPageViews = array_sum(array_column($timeSeries, 'value'));
|
||||
|
||||
// Falls keine Daten gefunden, schaue direkt in aggregierten Daten
|
||||
if ($totalPageViews === 0 && ! empty($aggregatedData)) {
|
||||
foreach ($aggregatedData as $period => $data) {
|
||||
if (isset($data['page_views_total'])) {
|
||||
$totalPageViews += $data['page_views_total'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Berechne geschätzte Metriken basierend auf verfügbaren Daten
|
||||
$estimatedVisitors = (int) ($totalPageViews * 0.7); // 70% unique visitors
|
||||
$bounceRate = 0.35; // Geschätzte Absprungrate
|
||||
$conversionRate = 0.02; // Geschätzte Conversion Rate
|
||||
|
||||
return [
|
||||
'total_page_views' => $totalPageViews,
|
||||
'unique_visitors' => $estimatedVisitors,
|
||||
'bounce_rate' => $bounceRate,
|
||||
'avg_session_duration' => 180.5,
|
||||
'conversion_rate' => $conversionRate,
|
||||
'error_rate' => 0.01,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function getTopPages(string $startDate, string $endDate, int $limit = 10): array
|
||||
{
|
||||
// Hole alle aggregierten Daten und suche nach page_views_* Mustern
|
||||
$aggregatedData = $this->storage->getAggregated($startDate, $endDate, 'hour');
|
||||
|
||||
$pageViews = [];
|
||||
foreach ($aggregatedData as $period => $data) {
|
||||
foreach ($data as $key => $value) {
|
||||
// Suche nach page_views_/path Pattern
|
||||
if (str_starts_with($key, 'page_views_/')) {
|
||||
$path = substr($key, 11); // Remove 'page_views_' prefix
|
||||
if (! isset($pageViews[$path])) {
|
||||
$pageViews[$path] = 0;
|
||||
}
|
||||
$pageViews[$path] += (int)$value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere nach Views
|
||||
arsort($pageViews);
|
||||
|
||||
// Formatiere für Ausgabe
|
||||
$topPages = [];
|
||||
foreach (array_slice($pageViews, 0, $limit, true) as $path => $views) {
|
||||
// Verwende deterministischen Wert basierend auf dem Pfad für konsistente Bounce-Rate
|
||||
$pathHash = crc32($path);
|
||||
$consistentBounceRate = 0.3 + (($pathHash % 21) / 100); // 0.30 bis 0.50
|
||||
|
||||
$topPages[] = [
|
||||
'path' => $path,
|
||||
'views' => $views,
|
||||
'unique_visitors' => (int)($views * 0.7), // Schätzung
|
||||
'bounce_rate' => round($consistentBounceRate, 2),
|
||||
];
|
||||
}
|
||||
|
||||
return $topPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function getTrafficSources(string $startDate, string $endDate): array
|
||||
{
|
||||
// Simuliere Traffic-Quellen
|
||||
return [
|
||||
'Direct' => 45,
|
||||
'Google' => 35,
|
||||
'Social Media' => 12,
|
||||
'Referral' => 8,
|
||||
];
|
||||
}
|
||||
}
|
||||
108
src/Application/Analytics/Service/AnalyticsRealTimeService.php
Normal file
108
src/Application/Analytics/Service/AnalyticsRealTimeService.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\Service;
|
||||
|
||||
use App\Framework\Analytics\Storage\AnalyticsStorage;
|
||||
|
||||
/**
|
||||
* Service für Real-Time Analytics
|
||||
* Verantwortlich für: Live-Daten, aktuelle Benutzer, Real-Time Events
|
||||
*/
|
||||
final readonly class AnalyticsRealTimeService
|
||||
{
|
||||
public function __construct(
|
||||
private AnalyticsStorage $storage
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{active_users: int, current_page_views: array<string, mixed>, recent_actions: array<int, mixed>, live_traffic: array<string, mixed>}
|
||||
*/
|
||||
public function getRealTimeData(): array
|
||||
{
|
||||
return [
|
||||
'active_users' => $this->getActiveUsers(),
|
||||
'current_page_views' => $this->getCurrentPageViews(),
|
||||
'recent_actions' => $this->getRecentActions(50),
|
||||
'live_traffic' => $this->getLiveTraffic(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getActiveUsers(): int
|
||||
{
|
||||
// Simuliere aktive Benutzer
|
||||
return rand(15, 45);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function getCurrentPageViews(): array
|
||||
{
|
||||
return [
|
||||
'/' => 12,
|
||||
'/products' => 8,
|
||||
'/about' => 3,
|
||||
'/contact' => 2,
|
||||
'/blog' => 5,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{timestamp: string, action: string, page: string, user: string}>
|
||||
*/
|
||||
public function getRecentActions(int $limit = 50): array
|
||||
{
|
||||
$actions = [];
|
||||
$now = time();
|
||||
|
||||
for ($i = 0; $i < $limit; $i++) {
|
||||
$actions[] = [
|
||||
'timestamp' => date('H:i:s', $now - ($i * 30)), // Alle 30 Sekunden
|
||||
'action' => $this->getRandomAction(),
|
||||
'page' => $this->getRandomPage(),
|
||||
'user_id' => 'user_' . rand(1000, 9999),
|
||||
'country' => $this->getRandomCountry(),
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{visitors_last_minute: int, visitors_last_5_minutes: int, visitors_last_30_minutes: int, peak_concurrent_users: int, current_bounce_rate: float}
|
||||
*/
|
||||
public function getLiveTraffic(): array
|
||||
{
|
||||
return [
|
||||
'visitors_last_minute' => rand(5, 15),
|
||||
'visitors_last_5_minutes' => rand(25, 75),
|
||||
'visitors_last_30_minutes' => rand(150, 450),
|
||||
'peak_concurrent_users' => rand(50, 120),
|
||||
'current_bounce_rate' => round(rand(25, 45) / 100, 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function getRandomAction(): string
|
||||
{
|
||||
$actions = ['page_view', 'button_click', 'form_submit', 'download', 'search'];
|
||||
|
||||
return $actions[array_rand($actions)];
|
||||
}
|
||||
|
||||
private function getRandomPage(): string
|
||||
{
|
||||
$pages = ['/', '/products', '/about', '/contact', '/blog', '/impressum', '/datenschutz'];
|
||||
|
||||
return $pages[array_rand($pages)];
|
||||
}
|
||||
|
||||
private function getRandomCountry(): string
|
||||
{
|
||||
$countries = ['Germany', 'Austria', 'Switzerland', 'Netherlands', 'France'];
|
||||
|
||||
return $countries[array_rand($countries)];
|
||||
}
|
||||
}
|
||||
237
src/Application/Analytics/Service/AnalyticsReportService.php
Normal file
237
src/Application/Analytics/Service/AnalyticsReportService.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\Service;
|
||||
|
||||
use App\Application\Analytics\ValueObject\ActionBreakdown;
|
||||
use App\Application\Analytics\ValueObject\BrowserBreakdown;
|
||||
use App\Application\Analytics\ValueObject\BusinessMetricsReport;
|
||||
use App\Application\Analytics\ValueObject\CountryBreakdown;
|
||||
use App\Application\Analytics\ValueObject\DeviceBreakdown;
|
||||
use App\Application\Analytics\ValueObject\UserBehaviorReport;
|
||||
use App\Framework\Analytics\AnalyticsCategory;
|
||||
use App\Framework\Analytics\Storage\AnalyticsStorage;
|
||||
|
||||
/**
|
||||
* Service für Analytics-Reports und Datenexport
|
||||
* Verantwortlich für: Report-Generierung, Datenexport, Filterung
|
||||
*/
|
||||
final readonly class AnalyticsReportService
|
||||
{
|
||||
public function __construct(
|
||||
private AnalyticsStorage $storage
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUserBehavior(string $startDate, string $endDate): UserBehaviorReport
|
||||
{
|
||||
$actions = ActionBreakdown::fromArray($this->getTopActions($startDate, $endDate, 20));
|
||||
$devices = DeviceBreakdown::fromArray($this->getDeviceBreakdown($startDate, $endDate));
|
||||
$browsers = BrowserBreakdown::fromArray($this->getBrowserBreakdown($startDate, $endDate));
|
||||
$countries = CountryBreakdown::fromArray($this->getCountryBreakdown($startDate, $endDate));
|
||||
|
||||
return new UserBehaviorReport($actions, $devices, $browsers, $countries);
|
||||
}
|
||||
|
||||
public function getBusinessMetrics(string $startDate, string $endDate): BusinessMetricsReport
|
||||
{
|
||||
return new BusinessMetricsReport(
|
||||
conversions: $this->getConversions($startDate, $endDate),
|
||||
revenue: $this->getRevenue($startDate, $endDate),
|
||||
goalCompletions: $this->getGoalCompletions($startDate, $endDate),
|
||||
funnelData: $this->getFunnelData($startDate, $endDate)
|
||||
);
|
||||
}
|
||||
|
||||
// Backward compatibility methods (deprecated)
|
||||
|
||||
/**
|
||||
* @deprecated Use getUserBehavior() which returns UserBehaviorReport instead
|
||||
* @return array<string, array<int|string, mixed>>
|
||||
*/
|
||||
public function getUserBehaviorArray(string $startDate, string $endDate): array
|
||||
{
|
||||
return $this->getUserBehavior($startDate, $endDate)->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getBusinessMetrics() which returns BusinessMetricsReport instead
|
||||
* @return array<string, array<int|string, mixed>>
|
||||
*/
|
||||
public function getBusinessMetricsArray(string $startDate, string $endDate): array
|
||||
{
|
||||
return $this->getBusinessMetrics($startDate, $endDate)->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getEvents(string $startDate, string $endDate, ?AnalyticsCategory $category = null, int $limit = 100, int $offset = 0): array
|
||||
{
|
||||
// Simuliere Events basierend auf verfügbaren Daten
|
||||
$events = [];
|
||||
$totalEvents = 50; // Simulation
|
||||
|
||||
for ($i = $offset; $i < min($offset + $limit, $totalEvents); $i++) {
|
||||
$events[] = [
|
||||
'id' => $i + 1,
|
||||
'timestamp' => date('Y-m-d H:i:s', strtotime($startDate) + ($i * 3600)),
|
||||
'action' => 'page_view',
|
||||
'category' => $category?->value ?? 'page_views',
|
||||
'properties' => [
|
||||
'path' => '/',
|
||||
'user_agent' => 'Mozilla/5.0...',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
public function getEventsCount(string $startDate, string $endDate, ?AnalyticsCategory $category = null): int
|
||||
{
|
||||
// Simuliere Event-Count
|
||||
return 150;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function exportData(string $startDate, string $endDate, ?AnalyticsCategory $category = null, string $format = 'json'): array
|
||||
{
|
||||
$data = [
|
||||
'overview' => $this->storage->getAggregated($startDate, $endDate, 'day'),
|
||||
'timeseries' => $this->storage->getTimeSeries('page_views', $startDate, $endDate, 'day'),
|
||||
'top_pages' => $this->storage->getTopList('page_views', $startDate, $endDate, 20),
|
||||
];
|
||||
|
||||
if ($format === 'csv') {
|
||||
// Convert to CSV format
|
||||
return $this->convertToCsv($data);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{action: string, count: int}>
|
||||
*/
|
||||
private function getTopActions(string $startDate, string $endDate, int $limit): array
|
||||
{
|
||||
return [
|
||||
['action' => 'page_view', 'count' => 1250],
|
||||
['action' => 'button_click', 'count' => 340],
|
||||
['action' => 'form_submit', 'count' => 89],
|
||||
['action' => 'download', 'count' => 45],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function getDeviceBreakdown(string $startDate, string $endDate): array
|
||||
{
|
||||
return [
|
||||
'Desktop' => 60,
|
||||
'Mobile' => 35,
|
||||
'Tablet' => 5,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function getBrowserBreakdown(string $startDate, string $endDate): array
|
||||
{
|
||||
return [
|
||||
'Chrome' => 65,
|
||||
'Firefox' => 20,
|
||||
'Safari' => 10,
|
||||
'Edge' => 5,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function getCountryBreakdown(string $startDate, string $endDate): array
|
||||
{
|
||||
return [
|
||||
'Germany' => 70,
|
||||
'Austria' => 15,
|
||||
'Switzerland' => 10,
|
||||
'Others' => 5,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function getConversions(string $startDate, string $endDate): array
|
||||
{
|
||||
return [
|
||||
'signup' => 45,
|
||||
'purchase' => 12,
|
||||
'download' => 89,
|
||||
'contact' => 23,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|int|float>
|
||||
*/
|
||||
private function getRevenue(string $startDate, string $endDate): array
|
||||
{
|
||||
return [
|
||||
'total' => 2345.67,
|
||||
'currency' => 'EUR',
|
||||
'transactions' => 12,
|
||||
'average_order_value' => 195.47,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function getGoalCompletions(string $startDate, string $endDate): array
|
||||
{
|
||||
return [
|
||||
'newsletter_signup' => 234,
|
||||
'contact_form' => 45,
|
||||
'product_view' => 1234,
|
||||
'checkout_start' => 67,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getFunnelData(string $startDate, string $endDate): array
|
||||
{
|
||||
return [
|
||||
'steps' => [
|
||||
['name' => 'Landing Page', 'visitors' => 1000, 'conversion' => 1.0],
|
||||
['name' => 'Product View', 'visitors' => 650, 'conversion' => 0.65],
|
||||
['name' => 'Add to Cart', 'visitors' => 130, 'conversion' => 0.20],
|
||||
['name' => 'Checkout', 'visitors' => 65, 'conversion' => 0.50],
|
||||
['name' => 'Purchase', 'visitors' => 32, 'conversion' => 0.49],
|
||||
],
|
||||
'overall_conversion' => 0.032,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function convertToCsv(array $data): array
|
||||
{
|
||||
// Vereinfachte CSV-Konvertierung
|
||||
return [
|
||||
'format' => 'csv',
|
||||
'data' => 'Date,Page Views,Unique Visitors\n' .
|
||||
implode('\n', array_map(fn ($date, $views) => "$date,$views,0", array_keys($data['timeseries'] ?? []), array_values($data['timeseries'] ?? []))),
|
||||
];
|
||||
}
|
||||
}
|
||||
110
src/Application/Analytics/ValueObject/ActionBreakdown.php
Normal file
110
src/Application/Analytics/ValueObject/ActionBreakdown.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\ValueObject;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* User action breakdown for analytics reporting
|
||||
*/
|
||||
final readonly class ActionBreakdown
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{action: string, count: int}> $actions
|
||||
*/
|
||||
public function __construct(
|
||||
public array $actions
|
||||
) {
|
||||
foreach ($actions as $action) {
|
||||
if ($action['count'] < 0) {
|
||||
throw new \InvalidArgumentException('Action counts cannot be negative');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{action: string, count: int}> $actions
|
||||
*/
|
||||
public static function fromArray(array $actions): self
|
||||
{
|
||||
return new self($actions);
|
||||
}
|
||||
|
||||
public function getTotal(): int
|
||||
{
|
||||
return array_sum(array_column($this->actions, 'count'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{action: string, count: int}|null
|
||||
*/
|
||||
public function getTopAction(): ?array
|
||||
{
|
||||
if (empty($this->actions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sorted = $this->actions;
|
||||
usort($sorted, fn ($a, $b) => $b['count'] <=> $a['count']);
|
||||
|
||||
return $sorted[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $limit
|
||||
* @return array<int, array{action: string, count: int, percentage: string}>
|
||||
*/
|
||||
public function getTopActions(int $limit = 10): array
|
||||
{
|
||||
$sorted = $this->actions;
|
||||
usort($sorted, fn ($a, $b) => $b['count'] <=> $a['count']);
|
||||
|
||||
$total = $this->getTotal();
|
||||
$topActions = array_slice($sorted, 0, $limit);
|
||||
|
||||
return array_map(function ($action) use ($total) {
|
||||
return [
|
||||
'action' => $action['action'],
|
||||
'count' => $action['count'],
|
||||
'percentage' => Percentage::fromRatio($action['count'], $total)->format(),
|
||||
];
|
||||
}, $topActions);
|
||||
}
|
||||
|
||||
public function getActionCount(string $actionName): int
|
||||
{
|
||||
foreach ($this->actions as $action) {
|
||||
if ($action['action'] === $actionName) {
|
||||
return $action['count'];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function getActionPercentage(string $actionName): Percentage
|
||||
{
|
||||
$count = $this->getActionCount($actionName);
|
||||
|
||||
return Percentage::fromRatio($count, $this->getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{action: string, count: int}>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy format for backward compatibility
|
||||
* @return array<int, array{action: string, count: int}>
|
||||
*/
|
||||
public function toAnalyticsArray(): array
|
||||
{
|
||||
return $this->actions;
|
||||
}
|
||||
}
|
||||
95
src/Application/Analytics/ValueObject/BrowserBreakdown.php
Normal file
95
src/Application/Analytics/ValueObject/BrowserBreakdown.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\ValueObject;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Browser usage breakdown for analytics reporting
|
||||
*/
|
||||
final readonly class BrowserBreakdown
|
||||
{
|
||||
public function __construct(
|
||||
public int $chrome,
|
||||
public int $firefox,
|
||||
public int $safari,
|
||||
public int $edge,
|
||||
) {
|
||||
if ($chrome < 0 || $firefox < 0 || $safari < 0 || $edge < 0) {
|
||||
throw new \InvalidArgumentException('Browser counts cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from legacy array format: ['Chrome' => 65, 'Firefox' => 20, ...]
|
||||
* @param array<string, int> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
chrome: $data['Chrome'] ?? 0,
|
||||
firefox: $data['Firefox'] ?? 0,
|
||||
safari: $data['Safari'] ?? 0,
|
||||
edge: $data['Edge'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
public function getTotal(): int
|
||||
{
|
||||
return $this->chrome + $this->firefox + $this->safari + $this->edge;
|
||||
}
|
||||
|
||||
public function getChromePercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->chrome, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getFirefoxPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->firefox, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getSafariPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->safari, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getEdgePercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->edge, $this->getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'Chrome' => $this->chrome,
|
||||
'Firefox' => $this->firefox,
|
||||
'Safari' => $this->safari,
|
||||
'Edge' => $this->edge,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy format for backward compatibility
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toAnalyticsArray(): array
|
||||
{
|
||||
return [
|
||||
'Chrome' => $this->chrome,
|
||||
'Firefox' => $this->firefox,
|
||||
'Safari' => $this->safari,
|
||||
'Edge' => $this->edge,
|
||||
'chrome_percentage' => $this->getChromePercentage()->format(),
|
||||
'firefox_percentage' => $this->getFirefoxPercentage()->format(),
|
||||
'safari_percentage' => $this->getSafariPercentage()->format(),
|
||||
'edge_percentage' => $this->getEdgePercentage()->format(),
|
||||
'total' => $this->getTotal(),
|
||||
];
|
||||
}
|
||||
}
|
||||
171
src/Application/Analytics/ValueObject/BusinessMetricsReport.php
Normal file
171
src/Application/Analytics/ValueObject/BusinessMetricsReport.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\ValueObject;
|
||||
|
||||
/**
|
||||
* Business metrics analytics report containing conversion and revenue data
|
||||
*/
|
||||
final readonly class BusinessMetricsReport
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $conversions
|
||||
* @param array<string, string|int|float> $revenue
|
||||
* @param array<string, int> $goalCompletions
|
||||
* @param array<string, mixed> $funnelData
|
||||
*/
|
||||
public function __construct(
|
||||
public array $conversions,
|
||||
public array $revenue,
|
||||
public array $goalCompletions,
|
||||
public array $funnelData,
|
||||
) {
|
||||
// Validate conversions - only check business logic
|
||||
foreach ($conversions as $count) {
|
||||
if ($count < 0) {
|
||||
throw new \InvalidArgumentException('Conversion counts cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate goal completions - only check business logic
|
||||
foreach ($goalCompletions as $completions) {
|
||||
if ($completions < 0) {
|
||||
throw new \InvalidArgumentException('Goal completion counts cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required revenue fields
|
||||
$requiredRevenueFields = ['total', 'currency', 'transactions', 'average_order_value'];
|
||||
foreach ($requiredRevenueFields as $field) {
|
||||
if (! array_key_exists($field, $revenue)) {
|
||||
throw new \InvalidArgumentException("Revenue must contain field: {$field}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getTotalConversions(): int
|
||||
{
|
||||
return array_sum($this->conversions);
|
||||
}
|
||||
|
||||
public function getTotalRevenue(): float
|
||||
{
|
||||
return (float) $this->revenue['total'];
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return (string) $this->revenue['currency'];
|
||||
}
|
||||
|
||||
public function getTransactionCount(): int
|
||||
{
|
||||
return (int) $this->revenue['transactions'];
|
||||
}
|
||||
|
||||
public function getAverageOrderValue(): float
|
||||
{
|
||||
return (float) $this->revenue['average_order_value'];
|
||||
}
|
||||
|
||||
public function getTotalGoalCompletions(): int
|
||||
{
|
||||
return array_sum($this->goalCompletions);
|
||||
}
|
||||
|
||||
public function getConversionRate(): float
|
||||
{
|
||||
if (! isset($this->funnelData['overall_conversion'])) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return (float) $this->funnelData['overall_conversion'];
|
||||
}
|
||||
|
||||
public function getTopConversionType(): ?string
|
||||
{
|
||||
if (empty($this->conversions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_key_first(
|
||||
array_slice($this->conversions, 0, 1, true)
|
||||
);
|
||||
}
|
||||
|
||||
public function getTopGoal(): ?string
|
||||
{
|
||||
if (empty($this->goalCompletions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sortedGoals = $this->goalCompletions;
|
||||
arsort($sortedGoals);
|
||||
|
||||
return array_key_first($sortedGoals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate revenue per conversion
|
||||
*/
|
||||
public function getRevenuePerConversion(): float
|
||||
{
|
||||
$totalConversions = $this->getTotalConversions();
|
||||
if ($totalConversions === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $this->getTotalRevenue() / $totalConversions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get business insights and KPIs
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getInsights(): array
|
||||
{
|
||||
return [
|
||||
'total_revenue' => $this->getTotalRevenue(),
|
||||
'currency' => $this->getCurrency(),
|
||||
'total_conversions' => $this->getTotalConversions(),
|
||||
'total_transactions' => $this->getTransactionCount(),
|
||||
'average_order_value' => $this->getAverageOrderValue(),
|
||||
'revenue_per_conversion' => $this->getRevenuePerConversion(),
|
||||
'overall_conversion_rate' => $this->getConversionRate() * 100, // as percentage
|
||||
'top_conversion_type' => $this->getTopConversionType(),
|
||||
'top_goal' => $this->getTopGoal(),
|
||||
'is_profitable' => $this->getTotalRevenue() > 0,
|
||||
'has_transactions' => $this->getTransactionCount() > 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy array format for backward compatibility
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'conversions' => $this->conversions,
|
||||
'revenue' => $this->revenue,
|
||||
'goals' => $this->goalCompletions,
|
||||
'funnel' => $this->funnelData,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced analytics array with insights
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toAnalyticsArray(): array
|
||||
{
|
||||
return [
|
||||
'conversions' => $this->conversions,
|
||||
'revenue' => $this->revenue,
|
||||
'goals' => $this->goalCompletions,
|
||||
'funnel' => $this->funnelData,
|
||||
'insights' => $this->getInsights(),
|
||||
];
|
||||
}
|
||||
}
|
||||
107
src/Application/Analytics/ValueObject/CountryBreakdown.php
Normal file
107
src/Application/Analytics/ValueObject/CountryBreakdown.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\ValueObject;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Country usage breakdown for analytics reporting
|
||||
*/
|
||||
final readonly class CountryBreakdown
|
||||
{
|
||||
public function __construct(
|
||||
public int $germany,
|
||||
public int $austria,
|
||||
public int $switzerland,
|
||||
public int $others,
|
||||
) {
|
||||
if ($germany < 0 || $austria < 0 || $switzerland < 0 || $others < 0) {
|
||||
throw new \InvalidArgumentException('Country counts cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from legacy array format: ['Germany' => 70, 'Austria' => 15, ...]
|
||||
* @param array<string, int> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
germany: $data['Germany'] ?? 0,
|
||||
austria: $data['Austria'] ?? 0,
|
||||
switzerland: $data['Switzerland'] ?? 0,
|
||||
others: $data['Others'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
public function getTotal(): int
|
||||
{
|
||||
return $this->germany + $this->austria + $this->switzerland + $this->others;
|
||||
}
|
||||
|
||||
public function getGermanyPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->germany, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getAustriaPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->austria, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getSwitzerlandPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->switzerland, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getOthersPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->others, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getDachRegionTotal(): int
|
||||
{
|
||||
return $this->germany + $this->austria + $this->switzerland;
|
||||
}
|
||||
|
||||
public function getDachRegionPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->getDachRegionTotal(), $this->getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'Germany' => $this->germany,
|
||||
'Austria' => $this->austria,
|
||||
'Switzerland' => $this->switzerland,
|
||||
'Others' => $this->others,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy format for backward compatibility
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toAnalyticsArray(): array
|
||||
{
|
||||
return [
|
||||
'Germany' => $this->germany,
|
||||
'Austria' => $this->austria,
|
||||
'Switzerland' => $this->switzerland,
|
||||
'Others' => $this->others,
|
||||
'germany_percentage' => $this->getGermanyPercentage()->format(),
|
||||
'austria_percentage' => $this->getAustriaPercentage()->format(),
|
||||
'switzerland_percentage' => $this->getSwitzerlandPercentage()->format(),
|
||||
'others_percentage' => $this->getOthersPercentage()->format(),
|
||||
'dach_total' => $this->getDachRegionTotal(),
|
||||
'dach_percentage' => $this->getDachRegionPercentage()->format(),
|
||||
'total' => $this->getTotal(),
|
||||
];
|
||||
}
|
||||
}
|
||||
84
src/Application/Analytics/ValueObject/DeviceBreakdown.php
Normal file
84
src/Application/Analytics/ValueObject/DeviceBreakdown.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\ValueObject;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
|
||||
/**
|
||||
* Device usage breakdown for analytics reporting
|
||||
*/
|
||||
final readonly class DeviceBreakdown
|
||||
{
|
||||
public function __construct(
|
||||
public int $desktop,
|
||||
public int $mobile,
|
||||
public int $tablet,
|
||||
) {
|
||||
if ($desktop < 0 || $mobile < 0 || $tablet < 0) {
|
||||
throw new \InvalidArgumentException('Device counts cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from legacy array format: ['Desktop' => 60, 'Mobile' => 35, 'Tablet' => 5]
|
||||
* @param array<string, int> $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
desktop: $data['Desktop'] ?? 0,
|
||||
mobile: $data['Mobile'] ?? 0,
|
||||
tablet: $data['Tablet'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
public function getTotal(): int
|
||||
{
|
||||
return $this->desktop + $this->mobile + $this->tablet;
|
||||
}
|
||||
|
||||
public function getDesktopPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->desktop, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getMobilePercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->mobile, $this->getTotal());
|
||||
}
|
||||
|
||||
public function getTabletPercentage(): Percentage
|
||||
{
|
||||
return Percentage::fromRatio($this->tablet, $this->getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'Desktop' => $this->desktop,
|
||||
'Mobile' => $this->mobile,
|
||||
'Tablet' => $this->tablet,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toAnalyticsArray(): array
|
||||
{
|
||||
return [
|
||||
'Desktop' => $this->desktop,
|
||||
'Mobile' => $this->mobile,
|
||||
'Tablet' => $this->tablet,
|
||||
'desktop_percentage' => $this->getDesktopPercentage()->format(),
|
||||
'mobile_percentage' => $this->getMobilePercentage()->format(),
|
||||
'tablet_percentage' => $this->getTabletPercentage()->format(),
|
||||
'total' => $this->getTotal(),
|
||||
];
|
||||
}
|
||||
}
|
||||
118
src/Application/Analytics/ValueObject/UserBehaviorReport.php
Normal file
118
src/Application/Analytics/ValueObject/UserBehaviorReport.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Analytics\ValueObject;
|
||||
|
||||
/**
|
||||
* User behavior analytics report containing all user interaction breakdowns
|
||||
*/
|
||||
final readonly class UserBehaviorReport
|
||||
{
|
||||
public function __construct(
|
||||
public ActionBreakdown $actions,
|
||||
public DeviceBreakdown $devices,
|
||||
public BrowserBreakdown $browsers,
|
||||
public CountryBreakdown $countries,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getTotalInteractions(): int
|
||||
{
|
||||
return $this->actions->getTotal();
|
||||
}
|
||||
|
||||
public function getTotalUniqueUsers(): int
|
||||
{
|
||||
// In a real implementation, this would be calculated differently
|
||||
// For now, we'll estimate based on device usage
|
||||
return $this->devices->getTotal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{action: string, count: int}|null
|
||||
*/
|
||||
public function getMostPopularAction(): ?array
|
||||
{
|
||||
return $this->actions->getTopAction();
|
||||
}
|
||||
|
||||
public function getDominantDevice(): string
|
||||
{
|
||||
$devices = $this->devices;
|
||||
$max = max($devices->desktop, $devices->mobile, $devices->tablet);
|
||||
|
||||
return match ($max) {
|
||||
$devices->desktop => 'Desktop',
|
||||
$devices->mobile => 'Mobile',
|
||||
$devices->tablet => 'Tablet',
|
||||
default => 'Unknown'
|
||||
};
|
||||
}
|
||||
|
||||
public function getDominantBrowser(): string
|
||||
{
|
||||
$browsers = $this->browsers;
|
||||
$max = max($browsers->chrome, $browsers->firefox, $browsers->safari, $browsers->edge);
|
||||
|
||||
return match ($max) {
|
||||
$browsers->chrome => 'Chrome',
|
||||
$browsers->firefox => 'Firefox',
|
||||
$browsers->safari => 'Safari',
|
||||
$browsers->edge => 'Edge',
|
||||
default => 'Unknown'
|
||||
};
|
||||
}
|
||||
|
||||
public function isDachRegionDominant(): bool
|
||||
{
|
||||
return $this->countries->getDachRegionPercentage()->getValue() > 80.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insights about user behavior patterns
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getInsights(): array
|
||||
{
|
||||
return [
|
||||
'total_interactions' => $this->getTotalInteractions(),
|
||||
'estimated_unique_users' => $this->getTotalUniqueUsers(),
|
||||
'most_popular_action' => $this->getMostPopularAction(),
|
||||
'dominant_device' => $this->getDominantDevice(),
|
||||
'dominant_browser' => $this->getDominantBrowser(),
|
||||
'dach_region_focus' => $this->isDachRegionDominant(),
|
||||
'mobile_first_audience' => $this->devices->getMobilePercentage()->getValue() > 50.0,
|
||||
'chrome_dominance' => $this->browsers->getChromePercentage()->getValue() > 60.0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy array format for backward compatibility
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'actions' => $this->actions->toArray(),
|
||||
'devices' => $this->devices->toArray(),
|
||||
'browsers' => $this->browsers->toArray(),
|
||||
'countries' => $this->countries->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced analytics array with percentages and insights
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toAnalyticsArray(): array
|
||||
{
|
||||
return [
|
||||
'actions' => $this->actions->toAnalyticsArray(),
|
||||
'devices' => $this->devices->toAnalyticsArray(),
|
||||
'browsers' => $this->browsers->toAnalyticsArray(),
|
||||
'countries' => $this->countries->toAnalyticsArray(),
|
||||
'insights' => $this->getInsights(),
|
||||
];
|
||||
}
|
||||
}
|
||||
472
src/Application/Analytics/templates/analytics-dashboard.view.php
Normal file
472
src/Application/Analytics/templates/analytics-dashboard.view.php
Normal file
@@ -0,0 +1,472 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Analytics Dashboard</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #718096;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.metrics-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.metrics-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.metric-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #f7fafc;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f7fafc;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.period-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.period-btn:hover {
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
border-color: #3182ce;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📊 Analytics Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="period-selector">
|
||||
<div class="period-buttons">
|
||||
<button class="period-btn active" data-days="7">7 Tage</button>
|
||||
<button class="period-btn" data-days="30">30 Tage</button>
|
||||
<button class="period-btn" data-days="90">90 Tage</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Seitenaufrufe</h3>
|
||||
<div class="stat-value" id="pageViews">-</div>
|
||||
<div class="stat-change positive" id="pageViewsChange">+0%</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>Eindeutige Besucher</h3>
|
||||
<div class="stat-value" id="uniqueVisitors">-</div>
|
||||
<div class="stat-change positive" id="visitorsChange">+0%</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>Absprungrate</h3>
|
||||
<div class="stat-value" id="bounceRate">-</div>
|
||||
<div class="stat-change negative" id="bounceChange">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>Conversion Rate</h3>
|
||||
<div class="stat-value" id="conversionRate">-</div>
|
||||
<div class="stat-change positive" id="conversionChange">+0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-grid">
|
||||
<div class="metrics-card">
|
||||
<div class="metrics-title">Traffic-Quellen</div>
|
||||
<div id="trafficSourcesList">
|
||||
<div class="loading">Lade Daten...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-card">
|
||||
<div class="metrics-title">Performance-Metriken</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">Durchschnittliche Sitzungsdauer</span>
|
||||
<span class="metric-value" id="avgSessionDuration">-</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">Fehlerrate</span>
|
||||
<span class="metric-value" id="errorRate">-</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">Conversion Rate</span>
|
||||
<span class="metric-value" id="performanceConversionRate">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
<div class="table-title">Top-Seiten</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seite</th>
|
||||
<th>Aufrufe</th>
|
||||
<th>Eindeutige Besucher</th>
|
||||
<th>Absprungrate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="topPagesTable">
|
||||
<tr>
|
||||
<td colspan="4" class="loading">Lade Daten...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class AnalyticsDashboard {
|
||||
constructor() {
|
||||
this.currentPeriod = 30;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.setupEventListeners();
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.querySelectorAll('.period-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('.period-btn').forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
this.currentPeriod = parseInt(e.target.dataset.days);
|
||||
this.loadData();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadOverview(),
|
||||
this.loadTopPages(),
|
||||
this.loadTrafficSources()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
this.showError('Fehler beim Laden der Analytics-Daten');
|
||||
}
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
try {
|
||||
const response = await fetch(`/admin/analytics/api/overview?days=${this.currentPeriod}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.updateOverviewStats(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading overview:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateOverviewStats(data) {
|
||||
document.getElementById('pageViews').textContent = this.formatNumber(data.total_page_views || 0);
|
||||
document.getElementById('uniqueVisitors').textContent = this.formatNumber(data.unique_visitors || 0);
|
||||
document.getElementById('bounceRate').textContent = this.formatPercent(data.bounce_rate || 0);
|
||||
document.getElementById('conversionRate').textContent = this.formatPercent(data.conversion_rate || 0);
|
||||
|
||||
// Update performance metrics
|
||||
document.getElementById('avgSessionDuration').textContent = this.formatDuration(data.avg_session_duration || 0);
|
||||
document.getElementById('errorRate').textContent = this.formatPercent(data.error_rate || 0);
|
||||
document.getElementById('performanceConversionRate').textContent = this.formatPercent(data.conversion_rate || 0);
|
||||
}
|
||||
|
||||
async loadTrafficSources() {
|
||||
try {
|
||||
const response = await fetch(`/admin/analytics/api/traffic-sources?days=${this.currentPeriod}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.updateTrafficSources(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading traffic sources:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateTrafficSources(data) {
|
||||
const container = document.getElementById('trafficSourcesList');
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
container.innerHTML = '<div class="loading">Keine Traffic-Daten verfügbar</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const total = Object.values(data).reduce((sum, val) => sum + val, 0);
|
||||
|
||||
container.innerHTML = Object.entries(data).map(([source, count]) => {
|
||||
const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : 0;
|
||||
return `
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">${source}</span>
|
||||
<span class="metric-value">${count} (${percentage}%)</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async loadTopPages() {
|
||||
try {
|
||||
const response = await fetch(`/admin/analytics/api/top-pages?days=${this.currentPeriod}&limit=10`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.updateTopPagesTable(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading top pages:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateTopPagesTable(data) {
|
||||
const tbody = document.getElementById('topPagesTable');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="loading">Keine Daten verfügbar</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(page => `
|
||||
<tr>
|
||||
<td>${page.path || '-'}</td>
|
||||
<td>${this.formatNumber(page.views || 0)}</td>
|
||||
<td>${this.formatNumber(page.unique_visitors || 0)}</td>
|
||||
<td>${this.formatPercent(page.bounce_rate || 0)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
formatPercent(num) {
|
||||
return (num * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const container = document.querySelector('.container');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error';
|
||||
errorDiv.textContent = message;
|
||||
container.insertBefore(errorDiv, container.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new AnalyticsDashboard();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user