*/ private const BREAKDOWN_COLUMNS = ['device_type', 'browser', 'os', 'country']; /** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,links_total:int} */ public function summaryForAccount(User $account): array { $links = ShortLink::query()->where('user_id', $account->id); $clicks = LinkClick::query()->whereIn('short_link_id', (clone $links)->select('id')); return [ 'total_clicks' => (int) (clone $links)->sum('clicks_total'), 'unique_clicks' => (int) (clone $links)->sum('unique_clicks_total'), 'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(), 'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(), 'links_total' => (int) (clone $links)->count(), ]; } /** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,last_clicked_at:\Illuminate\Support\Carbon|null} */ public function summaryForLink(ShortLink $link): array { $clicks = LinkClick::query()->where('short_link_id', $link->id); return [ 'total_clicks' => (int) $link->clicks_total, 'unique_clicks' => (int) $link->unique_clicks_total, 'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(), 'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(), 'last_clicked_at' => $link->last_clicked_at, ]; } /** @return Collection */ public function dailyClicksForAccount(User $account, int $days = 30): Collection { return $this->dailyClicks( LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')), $days, ); } /** @return Collection */ public function dailyClicksForLink(ShortLink $link, int $days = 30): Collection { return $this->dailyClicks( LinkClick::query()->where('short_link_id', $link->id), $days, ); } /** @return Collection */ private function dailyClicks($query, int $days): Collection { $rows = (clone $query) ->selectRaw('DATE(clicked_at) as date, COUNT(*) as total') ->where('clicked_at', '>=', now()->subDays($days - 1)->startOfDay()) ->groupBy('date') ->orderBy('date') ->get() ->keyBy('date'); return collect(range(0, $days - 1))->map(function (int $offset) use ($rows, $days) { $date = now()->subDays($days - 1 - $offset)->toDateString(); return (object) [ 'date' => $date, 'total' => (int) ($rows[$date]->total ?? 0), ]; }); } /** @return array */ public function breakdownForAccount(User $account, string $column, int $limit = 8): array { return $this->breakdown( LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')), $column, $limit, ); } /** @return array */ public function breakdownForLink(ShortLink $link, string $column, int $limit = 8): array { return $this->breakdown( LinkClick::query()->where('short_link_id', $link->id), $column, $limit, ); } /** @return array */ private function breakdown($query, string $column, int $limit): array { if (! in_array($column, self::BREAKDOWN_COLUMNS, true)) { throw new \InvalidArgumentException("Unsupported breakdown column: {$column}"); } return (clone $query) ->select($column, DB::raw('COUNT(*) as total')) ->whereNotNull($column) ->where($column, '!=', '') ->groupBy($column) ->orderByDesc('total') ->limit($limit) ->get() ->map(fn ($row) => [ 'label' => $this->breakdownLabel($column, (string) $row->{$column}), 'total' => (int) $row->total, ]) ->all(); } /** @return Collection */ public function topLinksForAccount(User $account, int $limit = 8): Collection { return ShortLink::query() ->where('user_id', $account->id) ->orderByDesc('clicks_total') ->limit($limit) ->get() ->map(fn (ShortLink $link) => [ 'label' => $link->label ?: $link->slug, 'slug' => $link->slug, 'total' => $link->clicks_total, 'url' => route('user.links.show', $link), ]); } /** @return Collection */ public function recentClicksForAccount(User $account, int $limit = 15): Collection { return LinkClick::query() ->with('shortLink:id,slug,label') ->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')) ->latest('clicked_at') ->limit($limit) ->get(); } /** @return Collection */ public function recentClicksForLink(ShortLink $link, int $limit = 20): Collection { return LinkClick::query() ->where('short_link_id', $link->id) ->latest('clicked_at') ->limit($limit) ->get(); } /** @return Collection */ public function referrersForAccount(User $account, int $limit = 8): Collection { return $this->referrers( LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')), $limit, ); } /** @return Collection */ public function referrersForLink(ShortLink $link, int $limit = 8): Collection { return $this->referrers( LinkClick::query()->where('short_link_id', $link->id), $limit, ); } /** @return Collection */ private function referrers($query, int $limit): Collection { return (clone $query) ->select('referer', DB::raw('COUNT(*) as total')) ->whereNotNull('referer') ->where('referer', '!=', '') ->groupBy('referer') ->orderByDesc('total') ->limit($limit) ->get() ->map(fn ($row) => [ 'label' => $this->refererLabel((string) $row->referer), 'total' => (int) $row->total, ]); } private function breakdownLabel(string $column, string $value): string { return match ($column) { 'device_type' => ucfirst($value), 'country' => LinkGeoResolver::countryLabel($value), default => $value, }; } private function refererLabel(string $referer): string { $host = parse_url($referer, PHP_URL_HOST); return is_string($host) && $host !== '' ? $host : $referer; } }