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 + +