From 9b01e08711fc317a9866582e1e5aa99e8e49cf10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Mon, 6 Oct 2025 15:54:52 +0200 Subject: [PATCH] Move visitor count server-side --- .../controllers/visit_analytics_controller.js | 34 --------- .../VisitorAnalyticsController.php | 12 ++++ src/Controller/Api/VisitController.php | 33 --------- src/Entity/Visit.php | 17 ++++- src/EventListener/VisitTrackingListener.php | 70 +++++++++++++++++++ src/Repository/VisitRepository.php | 45 ++++++++++++ templates/admin/analytics.html.twig | 40 ++++++++++- 7 files changed, 182 insertions(+), 69 deletions(-) delete mode 100644 assets/controllers/visit_analytics_controller.js delete mode 100644 src/Controller/Api/VisitController.php create mode 100644 src/EventListener/VisitTrackingListener.php diff --git a/assets/controllers/visit_analytics_controller.js b/assets/controllers/visit_analytics_controller.js deleted file mode 100644 index 5628d32..0000000 --- a/assets/controllers/visit_analytics_controller.js +++ /dev/null @@ -1,34 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; - -/** - * Simple analytics controller to record page visits - */ -export default class extends Controller { - static values = { - path: String - } - - connect() { - // Record the visit when the controller connects - this.recordVisit(); - } - - recordVisit() { - // Get the current route path - const path = this.pathValue || window.location.pathname; - - // Send visit data to API - fetch('/api/visit', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - route: path - }) - }) - .catch(error => { - console.error('Error recording visit:', error); - }); - } -} diff --git a/src/Controller/Administration/VisitorAnalyticsController.php b/src/Controller/Administration/VisitorAnalyticsController.php index 159a027..0d10489 100644 --- a/src/Controller/Administration/VisitorAnalyticsController.php +++ b/src/Controller/Administration/VisitorAnalyticsController.php @@ -25,10 +25,22 @@ class VisitorAnalyticsController extends AbstractController $last24hCount = $visitRepository->countVisitsSince($last24h); $last7dCount = $visitRepository->countVisitsSince($last7d); + // Unique session tracking + $uniqueVisitors24h = $visitRepository->countUniqueSessionsSince($last24h); + $uniqueVisitors7d = $visitRepository->countUniqueSessionsSince($last7d); + $totalUniqueVisitors = $visitRepository->countUniqueVisitors(); + + // Session-based visit breakdown + $sessionStats = $visitRepository->getVisitsBySession($last7d); + return $this->render('admin/analytics.html.twig', [ 'visitStats' => $visitStats, 'last24hCount' => $last24hCount, 'last7dCount' => $last7dCount, + 'uniqueVisitors24h' => $uniqueVisitors24h, + 'uniqueVisitors7d' => $uniqueVisitors7d, + 'totalUniqueVisitors' => $totalUniqueVisitors, + 'sessionStats' => $sessionStats, ]); } } diff --git a/src/Controller/Api/VisitController.php b/src/Controller/Api/VisitController.php deleted file mode 100644 index 61f7d80..0000000 --- a/src/Controller/Api/VisitController.php +++ /dev/null @@ -1,33 +0,0 @@ -getContent(), true); - - if (!isset($data['route']) || empty($data['route'])) { - return new JsonResponse(['error' => 'Route is required'], Response::HTTP_BAD_REQUEST); - } - - $route = $data['route']; - $visit = new Visit($route); - - $visitRepository->save($visit); - - return new JsonResponse(['success' => true]); - } -} diff --git a/src/Entity/Visit.php b/src/Entity/Visit.php index e50fb21..551bb9e 100644 --- a/src/Entity/Visit.php +++ b/src/Entity/Visit.php @@ -20,9 +20,13 @@ class Visit #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $visitedAt; - public function __construct(string $route) + #[ORM\Column(length: 255, nullable: true)] + private ?string $sessionId = null; + + public function __construct(string $route, ?string $sessionId = null) { $this->route = $route; + $this->sessionId = $sessionId; $this->visitedAt = new \DateTimeImmutable(); } @@ -46,4 +50,15 @@ class Visit { return $this->visitedAt; } + + public function getSessionId(): ?string + { + return $this->sessionId; + } + + public function setSessionId(?string $sessionId): self + { + $this->sessionId = $sessionId; + return $this; + } } diff --git a/src/EventListener/VisitTrackingListener.php b/src/EventListener/VisitTrackingListener.php new file mode 100644 index 0000000..7ade5be --- /dev/null +++ b/src/EventListener/VisitTrackingListener.php @@ -0,0 +1,70 @@ +isMainRequest()) { + return; + } + + $request = $event->getRequest(); + $route = $request->getPathInfo(); + + // Skip tracking for excluded routes (API, profiler, assets, etc.) + foreach (self::EXCLUDED_ROUTES as $excludedRoute) { + if (str_starts_with($route, $excludedRoute)) { + return; + } + } + + // Get session ID if user is logged in + $sessionId = null; + if ($this->security->getUser()) { + // Start session if not already started + if (!$request->hasSession() || !$request->getSession()->isStarted()) { + $request->getSession()->start(); + } + $sessionId = $request->getSession()->getId(); + } + + // Create and save the visit record + $visit = new Visit($route, $sessionId); + + try { + $this->visitRepository->save($visit); + } catch (\Exception $e) { + // Silently fail to avoid breaking the request + // You could log this error if needed + } + } +} diff --git a/src/Repository/VisitRepository.php b/src/Repository/VisitRepository.php index 3b9e98a..7ae525c 100644 --- a/src/Repository/VisitRepository.php +++ b/src/Repository/VisitRepository.php @@ -48,4 +48,49 @@ class VisitRepository extends ServiceEntityRepository return (int) $qb->getQuery()->getSingleScalarResult(); } + + /** + * Returns the count of unique sessions (logged-in users) since the given datetime. + */ + public function countUniqueSessionsSince(\DateTimeImmutable $since): int + { + $qb = $this->createQueryBuilder('v') + ->select('COUNT(DISTINCT v.sessionId)') + ->where('v.visitedAt >= :since') + ->andWhere('v.sessionId IS NOT NULL') + ->setParameter('since', $since, Types::DATETIME_IMMUTABLE); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + /** + * Returns visits grouped by session ID with counts. + */ + public function getVisitsBySession(\DateTimeImmutable $since = null): array + { + $qb = $this->createQueryBuilder('v') + ->select('v.sessionId, COUNT(v.id) as visitCount, MIN(v.visitedAt) as firstVisit, MAX(v.visitedAt) as lastVisit') + ->where('v.sessionId IS NOT NULL') + ->groupBy('v.sessionId') + ->orderBy('visitCount', 'DESC'); + + if ($since) { + $qb->andWhere('v.visitedAt >= :since') + ->setParameter('since', $since, Types::DATETIME_IMMUTABLE); + } + + return $qb->getQuery()->getResult(); + } + + /** + * Returns unique visitor count (distinct session IDs). + */ + public function countUniqueVisitors(): int + { + $qb = $this->createQueryBuilder('v') + ->select('COUNT(DISTINCT v.sessionId)') + ->where('v.sessionId IS NOT NULL'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } } diff --git a/templates/admin/analytics.html.twig b/templates/admin/analytics.html.twig index 94f420d..7dbb8db 100644 --- a/templates/admin/analytics.html.twig +++ b/templates/admin/analytics.html.twig @@ -14,6 +14,16 @@ +
+

Unique Logged-In Visitors

+ +

Tracked by session ID for logged-in users only

+
+

Visit Count by Route

@@ -39,8 +49,36 @@ {% endif %}
+
+

User Sessions (Last 7 Days)

+ {% if sessionStats|length > 0 %} + + + + + + + + + + + {% for stat in sessionStats %} + + + + + + + {% endfor %} + +
Session IDVisitsFirst VisitLast Visit
{{ stat.sessionId|slice(0, 12) }}...{{ stat.visitCount }}{{ stat.firstVisit|date('M d, H:i') }}{{ stat.lastVisit|date('M d, H:i') }}
+ {% else %} +

No logged-in visitor sessions recorded in the last 7 days.

+ {% endif %} +
+
-

This data shows the number of page visits per route. No personal user data is collected.

+

Visit tracking is now automated via event listener. Session IDs are captured for logged-in users to track unique visitors and user engagement patterns.

{% endblock %}