From 6ad100d3313aeeaed419ab58866d00fd0b359c89 Mon Sep 17 00:00:00 2001 From: Stefanescu Mihai Date: Wed, 18 Mar 2026 12:18:07 +0200 Subject: [PATCH] phase 2 --- PLAN.md | 12 +- app/Http/Controllers/DashboardController.php | 64 ++++++ app/Http/Controllers/WebsiteController.php | 6 +- app/Services/AnalyticsQueryService.php | 158 ++++++++++++++ .../components/date-range-picker.blade.php | 75 +++++++ resources/views/dashboard/show.blade.php | 197 ++++++++++++++++++ resources/views/layouts/app.blade.php | 1 + resources/views/websites/edit.blade.php | 2 +- resources/views/websites/index.blade.php | 4 +- resources/views/websites/show.blade.php | 63 ------ resources/views/websites/snippet.blade.php | 4 +- routes/web.php | 4 + 12 files changed, 513 insertions(+), 77 deletions(-) create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 app/Services/AnalyticsQueryService.php create mode 100644 resources/views/components/date-range-picker.blade.php create mode 100644 resources/views/dashboard/show.blade.php delete mode 100644 resources/views/websites/show.blade.php diff --git a/PLAN.md b/PLAN.md index bb8bbf9..725fbd3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -24,18 +24,18 @@ The platform needs user accounts and CRUD for tracked websites before anything e The main dashboard a user sees after selecting a website, showing key metrics at a glance. -- [ ] Create `DashboardController` scoped to a single website -- [ ] Build a date-range picker component (today, last 7 days, last 30 days, custom range) -- [ ] **Metric cards** — display for the selected period: +- [x] Create `DashboardController` scoped to a single website +- [x] Build a date-range picker component (today, last 7 days, last 30 days, custom range) +- [x] **Metric cards** — display for the selected period: - Total page views - Unique visitors - Total sessions - Bounce rate (% of sessions with 1 page view) - Average session duration - Average pages per session -- [ ] **Visitors chart** — line/area chart of visitors and page views over time (daily or hourly granularity) -- [ ] **Real-time widget** — show active visitors in the last 5 minutes (poll or WebSocket) -- [ ] Create an `AnalyticsQueryService` (or similar) to encapsulate all aggregation queries, keeping controllers thin +- [x] **Visitors chart** — line/area chart of visitors and page views over time (daily or hourly granularity) +- [x] **Real-time widget** — show active visitors in the last 5 minutes (poll or WebSocket) +- [x] Create an `AnalyticsQueryService` (or similar) to encapsulate all aggregation queries, keeping controllers thin --- diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..08eff82 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,64 @@ +authorize('view', $website); + + [$from, $to] = $this->parseDateRange($request); + + $service = AnalyticsQueryService::for($website, $from, $to); + $metrics = $service->summary(); + $chartData = $service->visitorsOverTime(); + $activeVisitors = AnalyticsQueryService::activeVisitors($website); + + return view('dashboard.show', compact( + 'website', 'metrics', 'chartData', 'activeVisitors', 'from', 'to', + )); + } + + public function realtime(Website $website): JsonResponse + { + $this->authorize('view', $website); + + return response()->json([ + 'active' => AnalyticsQueryService::activeVisitors($website), + ]); + } + + private function parseDateRange(Request $request): array + { + $range = $request->get('range', '7d'); + + $to = Carbon::today(); + + $from = match ($range) { + 'today' => Carbon::today(), + '7d' => Carbon::today()->subDays(6), + '30d' => Carbon::today()->subDays(29), + '90d' => Carbon::today()->subDays(89), + 'custom' => Carbon::parse($request->get('from', Carbon::today()->subDays(6)->toDateString())), + default => Carbon::today()->subDays(6), + }; + + if ($range === 'custom' && $request->has('to')) { + $to = Carbon::parse($request->get('to')); + } + + if ($from->gt($to)) { + [$from, $to] = [$to, $from]; + } + + return [$from, $to]; + } +} diff --git a/app/Http/Controllers/WebsiteController.php b/app/Http/Controllers/WebsiteController.php index 327b3f6..52e570e 100644 --- a/app/Http/Controllers/WebsiteController.php +++ b/app/Http/Controllers/WebsiteController.php @@ -31,11 +31,11 @@ class WebsiteController extends Controller ->with('success', 'Website added successfully.'); } - public function show(Website $website): View + public function show(Website $website): RedirectResponse { $this->authorize('view', $website); - return view('websites.show', compact('website')); + return redirect()->route('dashboard.show', $website); } public function edit(Website $website): View @@ -54,7 +54,7 @@ class WebsiteController extends Controller $website->update($data); return redirect() - ->route('websites.show', $website) + ->route('dashboard.show', $website) ->with('success', 'Website updated successfully.'); } diff --git a/app/Services/AnalyticsQueryService.php b/app/Services/AnalyticsQueryService.php new file mode 100644 index 0000000..5c795e8 --- /dev/null +++ b/app/Services/AnalyticsQueryService.php @@ -0,0 +1,158 @@ +startOfDay(), $to->endOfDay()); + } + + // ---- Summary Metrics ---- + + public function totalPageViews(): int + { + return PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$this->from, $this->to]) + ->count(); + } + + public function uniqueVisitors(): int + { + return PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$this->from, $this->to]) + ->distinct('visitor_id') + ->count('visitor_id'); + } + + public function totalSessions(): int + { + return AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->count(); + } + + public function bounceRate(): float + { + $total = $this->totalSessions(); + if ($total === 0) { + return 0; + } + + $bounced = AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->where('is_bounce', true) + ->count(); + + return round(($bounced / $total) * 100, 1); + } + + public function avgSessionDuration(): float + { + return round( + (float) AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->avg('duration') ?? 0, + ); + } + + public function avgPagesPerSession(): float + { + return round( + (float) AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->avg('pages_count') ?? 0, + 1, + ); + } + + public function summary(): array + { + return [ + 'page_views' => $this->totalPageViews(), + 'visitors' => $this->uniqueVisitors(), + 'sessions' => $this->totalSessions(), + 'bounce_rate' => $this->bounceRate(), + 'avg_duration' => $this->avgSessionDuration(), + 'avg_pages' => $this->avgPagesPerSession(), + ]; + } + + // ---- Time Series ---- + + public function visitorsOverTime(): Collection + { + $days = $this->from->diffInDays($this->to); + $groupFormat = $days <= 2 ? '%Y-%m-%d %H:00' : '%Y-%m-%d'; + $carbonFormat = $days <= 2 ? 'Y-m-d H:00' : 'Y-m-d'; + + $pageViews = PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$this->from, $this->to]) + ->select( + DB::raw("strftime('{$groupFormat}', viewed_at) as period"), + DB::raw('COUNT(*) as page_views'), + DB::raw('COUNT(DISTINCT visitor_id) as visitors'), + ) + ->groupBy('period') + ->orderBy('period') + ->get() + ->keyBy('period'); + + $sessions = AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->select( + DB::raw("strftime('{$groupFormat}', started_at) as period"), + DB::raw('COUNT(*) as sessions'), + ) + ->groupBy('period') + ->orderBy('period') + ->get() + ->keyBy('period'); + + $periods = collect(); + $cursor = $this->from->copy(); + $increment = $days <= 2 ? 'addHour' : 'addDay'; + + while ($cursor->lte($this->to)) { + $key = $cursor->format($carbonFormat); + $pv = $pageViews->get($key); + $sess = $sessions->get($key); + + $periods->push([ + 'period' => $key, + 'page_views' => $pv ? $pv->page_views : 0, + 'visitors' => $pv ? $pv->visitors : 0, + 'sessions' => $sess ? $sess->sessions : 0, + ]); + + $cursor->$increment(); + } + + return $periods; + } + + // ---- Real-Time ---- + + public static function activeVisitors(Website $website, int $minutes = 5): int + { + return AnalyticsSession::where('website_id', $website->id) + ->where('ended_at', '>=', Carbon::now()->subMinutes($minutes)) + ->distinct('visitor_id') + ->count('visitor_id'); + } +} diff --git a/resources/views/components/date-range-picker.blade.php b/resources/views/components/date-range-picker.blade.php new file mode 100644 index 0000000..f2a25fd --- /dev/null +++ b/resources/views/components/date-range-picker.blade.php @@ -0,0 +1,75 @@ +@props(['website', 'from', 'to']) + +@php + $currentRange = request('range', '7d'); + $presets = [ + 'today' => 'Today', + '7d' => 'Last 7 days', + '30d' => 'Last 30 days', + '90d' => 'Last 90 days', + ]; +@endphp + +
+ + +
+
+ @foreach ($presets as $key => $label) + + {{ $label }} + + @endforeach + +
+ +
+ +
+ + +
+
+ + +
+ +
+
+
+
diff --git a/resources/views/dashboard/show.blade.php b/resources/views/dashboard/show.blade.php new file mode 100644 index 0000000..f526124 --- /dev/null +++ b/resources/views/dashboard/show.blade.php @@ -0,0 +1,197 @@ + + +
+
+ + + + + +

+ {{ $website->name }} +

+ {{ $website->domain }} +
+ +
+ {{-- Real-time indicator --}} +
+ + + + + + active now + +
+ + + + +
+
+
+ +
+
+ + {{-- Metric Cards --}} +
+
+
Page Views
+
{{ number_format($metrics['page_views']) }}
+
+
+
Unique Visitors
+
{{ number_format($metrics['visitors']) }}
+
+
+
Sessions
+
{{ number_format($metrics['sessions']) }}
+
+
+
Bounce Rate
+
{{ $metrics['bounce_rate'] }}%
+
+
+
Avg. Duration
+
{{ gmdate('i:s', (int) $metrics['avg_duration']) }}
+
+
+
Pages / Session
+
{{ $metrics['avg_pages'] }}
+
+
+ + {{-- Visitors Chart --}} +
+
+

Visitors & Page Views

+
+ + Visitors + + + Page Views + + + Sessions + +
+
+ +
+ +
+
+ +
+
+ + @push('scripts') + + + @endpush +
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index c5ff315..bf3dd4c 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -32,5 +32,6 @@ {{ $slot }} + @stack('scripts') diff --git a/resources/views/websites/edit.blade.php b/resources/views/websites/edit.blade.php index 55afaed..7a7b878 100644 --- a/resources/views/websites/edit.blade.php +++ b/resources/views/websites/edit.blade.php @@ -35,7 +35,7 @@
{{ __('Save Changes') }} - {{ __('Cancel') }} + {{ __('Cancel') }}
diff --git a/resources/views/websites/index.blade.php b/resources/views/websites/index.blade.php index b10ede7..af82c0f 100644 --- a/resources/views/websites/index.blade.php +++ b/resources/views/websites/index.blade.php @@ -40,7 +40,7 @@

- + {{ $website->name }}

@@ -53,7 +53,7 @@

{{ $website->tracking_id }}

diff --git a/resources/views/websites/show.blade.php b/resources/views/websites/show.blade.php deleted file mode 100644 index 8ae9f19..0000000 --- a/resources/views/websites/show.blade.php +++ /dev/null @@ -1,63 +0,0 @@ - - - - - -
-
- @if (session('success')) -
-

{{ session('success') }}

-
- @endif - -
-
-
{{ __('Domain') }}
-
{{ $website->domain }}
-
-
-
{{ __('Tracking ID') }}
-
{{ $website->tracking_id }}
-
-
-
{{ __('Status') }}
-
- - {{ $website->is_active ? 'Active' : 'Inactive' }} - -
-
-
-
{{ __('Created') }}
-
{{ $website->created_at->format('M j, Y') }}
-
-
- -
-
- - - -

{{ __('Analytics dashboard will be available here once data is collected.') }}

-

{{ __('Make sure you have added the tracking snippet to your website.') }}

- - {{ __('View tracking snippet') }} → - -
-
-
-
-
diff --git a/resources/views/websites/snippet.blade.php b/resources/views/websites/snippet.blade.php index cf6239b..fe9db1e 100644 --- a/resources/views/websites/snippet.blade.php +++ b/resources/views/websites/snippet.blade.php @@ -4,8 +4,8 @@

{{ __('Tracking Snippet') }}: {{ $website->name }}

- - ← {{ __('Back to website') }} + + ← {{ __('Back to dashboard') }}
diff --git a/routes/web.php b/routes/web.php index 287a4e6..af3a36b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { Route::resource('websites', WebsiteController::class); Route::get('websites/{website}/snippet', [WebsiteController::class, 'snippet'])->name('websites.snippet'); + + Route::get('websites/{website}/dashboard', [DashboardController::class, 'show'])->name('dashboard.show'); + Route::get('websites/{website}/realtime', [DashboardController::class, 'realtime'])->name('dashboard.realtime'); }); Route::middleware('auth')->group(function () {