Add device, platform, and country analytics to link show page.
Deploy Ladill Link / deploy (push) Successful in 32s
Deploy Ladill Link / deploy (push) Successful in 32s
Record parsed user-agent and geo data on clicks, then surface breakdowns and richer recent-click context on per-link and account analytics views. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,11 +5,15 @@ namespace App\Services\Link;
|
||||
use App\Models\LinkClick;
|
||||
use App\Models\ShortLink;
|
||||
use App\Models\User;
|
||||
use App\Support\Link\LinkGeoResolver;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LinkAnalyticsService
|
||||
{
|
||||
/** @var list<string> */
|
||||
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
|
||||
{
|
||||
@@ -25,12 +29,43 @@ class LinkAnalyticsService
|
||||
];
|
||||
}
|
||||
|
||||
/** @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<int, object{date:string,total:int}> */
|
||||
public function dailyClicksForAccount(User $account, int $days = 30): Collection
|
||||
{
|
||||
$rows = LinkClick::query()
|
||||
return $this->dailyClicks(
|
||||
LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')),
|
||||
$days,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection<int, object{date:string,total:int}> */
|
||||
public function dailyClicksForLink(ShortLink $link, int $days = 30): Collection
|
||||
{
|
||||
return $this->dailyClicks(
|
||||
LinkClick::query()->where('short_link_id', $link->id),
|
||||
$days,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection<int, object{date:string,total:int}> */
|
||||
private function dailyClicks($query, int $days): Collection
|
||||
{
|
||||
$rows = (clone $query)
|
||||
->selectRaw('DATE(clicked_at) as date, COUNT(*) as total')
|
||||
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
|
||||
->where('clicked_at', '>=', now()->subDays($days - 1)->startOfDay())
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
@@ -47,6 +82,48 @@ class LinkAnalyticsService
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<int, array{label:string,total:int}> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
public function topLinksForAccount(User $account, int $limit = 8): Collection
|
||||
{
|
||||
@@ -74,12 +151,39 @@ class LinkAnalyticsService
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection<int, LinkClick> */
|
||||
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<int, array{label:string,total:int}> */
|
||||
public function referrersForAccount(User $account, int $limit = 8): Collection
|
||||
{
|
||||
return LinkClick::query()
|
||||
return $this->referrers(
|
||||
LinkClick::query()->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id')),
|
||||
$limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection<int, array{label:string,total:int}> */
|
||||
public function referrersForLink(ShortLink $link, int $limit = 8): Collection
|
||||
{
|
||||
return $this->referrers(
|
||||
LinkClick::query()->where('short_link_id', $link->id),
|
||||
$limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection<int, array{label:string,total:int}> */
|
||||
private function referrers($query, int $limit): Collection
|
||||
{
|
||||
return (clone $query)
|
||||
->select('referer', DB::raw('COUNT(*) as total'))
|
||||
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
|
||||
->whereNotNull('referer')
|
||||
->where('referer', '!=', '')
|
||||
->groupBy('referer')
|
||||
@@ -92,6 +196,15 @@ class LinkAnalyticsService
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user