feat: add tag discovery analytics and reporting

This commit is contained in:
2026-03-17 18:23:38 +01:00
parent b3fc889452
commit 2728644477
29 changed files with 2660 additions and 112 deletions

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Services\Analytics;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class TagInteractionReportService
{
public function buildReport(string $from, string $to, int $limit = 20): array
{
return [
'overview' => $this->overview($from, $to),
'daily_clicks' => $this->dailyClicks($from, $to),
'by_surface' => $this->bySurface($from, $to),
'top_tags' => $this->topTags($from, $to, $limit),
'top_queries' => $this->topQueries($from, $to, $limit),
'top_transitions' => $this->topTransitions($from, $to, $limit),
'latest_aggregated_date' => $this->latestAggregatedDate(),
];
}
private function overview(string $from, string $to): array
{
$row = DB::table('tag_interaction_events')
->selectRaw('COUNT(*) AS total_clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw("COUNT(DISTINCT CASE WHEN tag_slug IS NOT NULL AND tag_slug <> '' THEN tag_slug END) AS distinct_tags")
->selectRaw('MAX(occurred_at) AS latest_event_at')
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->first();
return [
'total_clicks' => (int) ($row->total_clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'distinct_tags' => (int) ($row->distinct_tags ?? 0),
'latest_event_at' => $row->latest_event_at,
];
}
private function dailyClicks(string $from, string $to): array
{
if (Schema::hasTable('tag_interaction_daily_metrics')) {
return DB::table('tag_interaction_daily_metrics')
->selectRaw('metric_date')
->selectRaw('SUM(clicks) AS clicks')
->whereBetween('metric_date', [$from, $to])
->groupBy('metric_date')
->orderBy('metric_date')
->get()
->map(static fn ($row): array => [
'date' => (string) $row->metric_date,
'clicks' => (int) ($row->clicks ?? 0),
])
->all();
}
return DB::table('tag_interaction_events')
->selectRaw('event_date')
->selectRaw('COUNT(*) AS clicks')
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->groupBy('event_date')
->orderBy('event_date')
->get()
->map(static fn ($row): array => [
'date' => (string) $row->event_date,
'clicks' => (int) ($row->clicks ?? 0),
])
->all();
}
private function bySurface(string $from, string $to): array
{
return DB::table('tag_interaction_events')
->selectRaw('surface')
->selectRaw('COUNT(*) AS clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw('AVG(position) AS avg_position')
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->groupBy('surface')
->orderByDesc('clicks')
->get()
->map(static fn ($row): array => [
'surface' => (string) $row->surface,
'clicks' => (int) ($row->clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
])
->all();
}
private function topTags(string $from, string $to, int $limit): array
{
return DB::table('tag_interaction_events')
->selectRaw('tag_slug')
->selectRaw('COUNT(*) AS clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw("SUM(CASE WHEN surface IN ('related_chip', 'related_cluster', 'top_companion') THEN 1 ELSE 0 END) AS recommendation_clicks")
->selectRaw("SUM(CASE WHEN surface IN ('search_suggestion', 'rescue_suggestion') THEN 1 ELSE 0 END) AS search_clicks")
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->whereNotNull('tag_slug')
->where('tag_slug', '<>', '')
->groupBy('tag_slug')
->orderByDesc('clicks')
->limit($limit)
->get()
->map(static fn ($row): array => [
'tag_slug' => (string) $row->tag_slug,
'clicks' => (int) ($row->clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'recommendation_clicks' => (int) ($row->recommendation_clicks ?? 0),
'search_clicks' => (int) ($row->search_clicks ?? 0),
])
->all();
}
private function topQueries(string $from, string $to, int $limit): array
{
return DB::table('tag_interaction_events')
->selectRaw("LOWER(TRIM(query)) AS query")
->selectRaw('COUNT(*) AS clicks')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw("COUNT(DISTINCT CASE WHEN tag_slug IS NOT NULL AND tag_slug <> '' THEN tag_slug END) AS resolved_tags")
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->whereNotNull('query')
->whereRaw("TRIM(query) <> ''")
->groupBy(DB::raw("LOWER(TRIM(query))"))
->orderByDesc('clicks')
->limit($limit)
->get()
->map(static fn ($row): array => [
'query' => (string) $row->query,
'clicks' => (int) ($row->clicks ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'resolved_tags' => (int) ($row->resolved_tags ?? 0),
])
->all();
}
private function topTransitions(string $from, string $to, int $limit): array
{
return DB::table('tag_interaction_events')
->selectRaw('source_tag_slug')
->selectRaw('tag_slug')
->selectRaw('COUNT(*) AS clicks')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw('AVG(position) AS avg_position')
->whereBetween('event_date', [$from, $to])
->where('event_type', 'click')
->whereNotNull('source_tag_slug')
->whereNotNull('tag_slug')
->where('source_tag_slug', '<>', '')
->where('tag_slug', '<>', '')
->groupBy('source_tag_slug', 'tag_slug')
->orderByDesc('clicks')
->limit($limit)
->get()
->map(static fn ($row): array => [
'source_tag_slug' => (string) $row->source_tag_slug,
'tag_slug' => (string) $row->tag_slug,
'clicks' => (int) ($row->clicks ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
])
->all();
}
private function latestAggregatedDate(): ?string
{
if (!Schema::hasTable('tag_interaction_daily_metrics')) {
return null;
}
$date = DB::table('tag_interaction_daily_metrics')->max('metric_date');
return $date ? (string) $date : null;
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace App\Services\Tags;
use App\Models\Tag;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class TagDiscoveryService
{
/**
* @return array<int, string>
*/
private function discoverySelectColumns(): array
{
return ['tags.id', 'tags.name', 'tags.slug', 'tags.usage_count'];
}
public function featuredTags(int $limit = 6, int $windowDays = 14): Collection
{
return $this->activeTagsQuery($windowDays)
->withCount('artworks')
->orderByDesc('recent_clicks')
->orderByDesc('usage_count')
->orderByDesc('artworks_count')
->orderBy('name')
->limit($limit)
->get();
}
public function risingTags(Collection $featuredTags, int $limit = 12, int $windowDays = 14): Collection
{
$featuredSlugs = $featuredTags->pluck('slug')->filter()->values();
$risingTags = $this->activeTagsQuery($windowDays)
->withCount('artworks')
->when($featuredSlugs->isNotEmpty(), function ($builder) use ($featuredSlugs): void {
$builder->whereNotIn('tags.slug', $featuredSlugs->all());
})
->when($this->hasDailyMetrics(), function ($builder): void {
$builder->whereRaw('COALESCE(tag_momentum.recent_clicks, 0) > 0');
})
->orderByDesc('recent_clicks')
->orderByDesc('artworks_count')
->orderBy('name')
->limit($limit)
->get();
if ($risingTags->count() >= $limit) {
return $risingTags;
}
$excludeSlugs = $risingTags
->pluck('slug')
->merge($featuredSlugs)
->filter()
->values();
$fallback = $this->activeTagsQuery($windowDays)
->withCount('artworks')
->when($excludeSlugs->isNotEmpty(), function ($builder) use ($excludeSlugs): void {
$builder->whereNotIn('tags.slug', $excludeSlugs->all());
})
->orderByDesc('usage_count')
->orderByDesc('artworks_count')
->orderBy('name')
->limit($limit - $risingTags->count())
->get();
return $risingTags->concat($fallback)->values();
}
public function paginatedTags(string $query = '', int $perPage = 48, int $windowDays = 14): LengthAwarePaginator
{
$tagsQuery = $this->activeTagsQuery($windowDays)
->withCount('artworks');
if ($query !== '') {
$tagsQuery->where(function ($builder) use ($query): void {
$builder
->where('tags.name', 'like', '%' . $query . '%')
->orWhere('tags.slug', 'like', '%' . $query . '%');
});
}
return $tagsQuery
->orderByDesc('recent_clicks')
->orderByDesc('usage_count')
->orderByDesc('artworks_count')
->orderBy('name')
->paginate($perPage)
->withQueryString();
}
public function stats(int $matchingTotal, int $windowDays = 14): array
{
$activeTags = $this->activeTagsQuery($windowDays);
return [
'active' => (clone $activeTags)->count(),
'usage' => (clone $activeTags)->sum('usage_count'),
'matching' => $matchingTotal,
'recent_clicks' => $this->hasDailyMetrics()
? (int) DB::table('tag_interaction_daily_metrics')
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
->sum('clicks')
: 0,
];
}
public function popularTags(int $limit = 20, int $windowDays = 14): Collection
{
return $this->activeTagsQuery($windowDays)
->orderByDesc('recent_clicks')
->orderByDesc('usage_count')
->orderBy('name')
->limit($limit)
->get();
}
public function searchSuggestions(string $query, int $limit = 20, int $windowDays = 14): Collection
{
$normalizedQuery = trim($query);
return $this->activeTagsQuery($windowDays)
->when($normalizedQuery !== '', function ($builder) use ($normalizedQuery): void {
$builder->where(function ($subQuery) use ($normalizedQuery): void {
$subQuery->where('tags.name', 'like', $normalizedQuery . '%')
->orWhere('tags.slug', 'like', $normalizedQuery . '%');
});
})
->orderByDesc('recent_clicks')
->orderByDesc('usage_count')
->orderBy('name')
->limit($limit)
->get();
}
public function relatedTags(Tag $tag, int $limit = 8, int $windowDays = 14): Collection
{
return DB::table('artwork_tag as current_tag')
->join('artwork_tag as related_tag', 'related_tag.artwork_id', '=', 'current_tag.artwork_id')
->join('tags', 'tags.id', '=', 'related_tag.tag_id')
->select([
'tags.name',
'tags.slug',
'tags.usage_count',
])
->selectRaw('COUNT(DISTINCT current_tag.artwork_id) as shared_artworks_count')
->when($this->hasDailyMetrics(), function ($builder) use ($tag, $windowDays): void {
$transitionMomentum = DB::table('tag_interaction_daily_metrics')
->selectRaw('tag_slug')
->selectRaw('SUM(clicks) AS transition_clicks')
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
->where('surface', '!=', 'recent_search')
->where('source_tag_slug', $tag->slug)
->where('tag_slug', '<>', '')
->groupBy('tag_slug');
$builder
->leftJoinSub($transitionMomentum, 'tag_transition_momentum', function ($join): void {
$join->on('tag_transition_momentum.tag_slug', '=', 'tags.slug');
})
->selectRaw('COALESCE(tag_transition_momentum.transition_clicks, 0) as transition_clicks')
->groupBy('tag_transition_momentum.transition_clicks')
->orderByDesc('transition_clicks');
})
->where('current_tag.tag_id', '=', $tag->getKey())
->where('related_tag.tag_id', '!=', $tag->getKey())
->where('tags.is_active', true)
->groupBy('tags.id', 'tags.name', 'tags.slug', 'tags.usage_count')
->orderByRaw('COUNT(DISTINCT current_tag.artwork_id) DESC')
->orderByDesc('tags.usage_count')
->limit($limit)
->get();
}
private function activeTagsQuery(int $windowDays)
{
$query = Tag::query()
->select($this->discoverySelectColumns())
->where('tags.is_active', true);
if (! $this->hasDailyMetrics()) {
return $query->selectRaw('0 AS recent_clicks');
}
$tagMomentum = DB::table('tag_interaction_daily_metrics')
->selectRaw('tag_slug')
->selectRaw('SUM(clicks) AS recent_clicks')
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
->where('tag_slug', '<>', '')
->groupBy('tag_slug');
return $query
->leftJoinSub($tagMomentum, 'tag_momentum', function ($join): void {
$join->on('tag_momentum.tag_slug', '=', 'tags.slug');
})
->selectRaw('COALESCE(tag_momentum.recent_clicks, 0) AS recent_clicks');
}
private function hasDailyMetrics(): bool
{
return Schema::hasTable('tag_interaction_daily_metrics');
}
private function windowStartDate(int $windowDays): string
{
return now()->subDays(max(0, $windowDays - 1))->toDateString();
}
}