diff --git a/app/Console/Commands/AggregateTagInteractionAnalyticsCommand.php b/app/Console/Commands/AggregateTagInteractionAnalyticsCommand.php new file mode 100644 index 00000000..f650e011 --- /dev/null +++ b/app/Console/Commands/AggregateTagInteractionAnalyticsCommand.php @@ -0,0 +1,72 @@ +option('date') + ? (string) $this->option('date') + : now()->subDay()->toDateString(); + + $normalizedTag = "COALESCE(tag_slug, '')"; + $normalizedSourceTag = "COALESCE(source_tag_slug, '')"; + $normalizedQuery = "LOWER(TRIM(COALESCE(query, '')))"; + + $rows = DB::table('tag_interaction_events') + ->selectRaw('surface') + ->selectRaw("{$normalizedTag} AS tag_slug") + ->selectRaw("{$normalizedSourceTag} AS source_tag_slug") + ->selectRaw("{$normalizedQuery} AS query") + ->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') + ->whereDate('event_date', $date) + ->where('event_type', 'click') + ->groupBy('surface', DB::raw($normalizedTag), DB::raw($normalizedSourceTag), DB::raw($normalizedQuery)) + ->get(); + + DB::transaction(function () use ($date, $rows): void { + DB::table('tag_interaction_daily_metrics') + ->where('metric_date', $date) + ->delete(); + + $payload = $rows->map(static function ($row) use ($date): array { + return [ + 'metric_date' => $date, + 'surface' => (string) $row->surface, + 'tag_slug' => trim((string) ($row->tag_slug ?? '')), + 'source_tag_slug' => trim((string) ($row->source_tag_slug ?? '')), + 'query' => trim((string) ($row->query ?? '')), + '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), + 'created_at' => now(), + 'updated_at' => now(), + ]; + })->all(); + + foreach (array_chunk($payload, 500) as $chunk) { + if ($chunk !== []) { + DB::table('tag_interaction_daily_metrics')->insert($chunk); + } + } + }); + + $this->info("Aggregated tag interaction analytics for {$date}."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SeedTagInteractionDemoCommand.php b/app/Console/Commands/SeedTagInteractionDemoCommand.php new file mode 100644 index 00000000..1658f9f9 --- /dev/null +++ b/app/Console/Commands/SeedTagInteractionDemoCommand.php @@ -0,0 +1,167 @@ +environment(['local', 'testing']) && ! $this->option('force')) { + $this->error('This command is restricted to local/testing unless --force is provided.'); + + return self::FAILURE; + } + + $days = max(1, min(60, (int) $this->option('days'))); + $perDay = max(10, min(500, (int) $this->option('per-day'))); + + $tags = Tag::query() + ->where('is_active', true) + ->orderByDesc('usage_count') + ->limit(20) + ->get(['id', 'name', 'slug', 'usage_count']); + + if ($tags->count() < 2) { + $this->error('At least two active tags are required to generate demo interaction data.'); + + return self::FAILURE; + } + + $transitions = $this->buildTransitionMap($tags); + + if ($this->option('refresh')) { + DB::table('tag_interaction_events') + ->where('meta->seeded_demo', true) + ->delete(); + } + + $now = now(); + + for ($offset = $days - 1; $offset >= 0; $offset--) { + $date = Carbon::today()->subDays($offset); + $rows = []; + + for ($index = 0; $index < $perDay; $index++) { + $surface = $this->pickSurface(); + $sourceTag = $tags->random(); + $targetTag = $this->pickTargetTag($surface, $sourceTag->slug, $transitions, $tags); + $query = in_array($surface, ['search_suggestion', 'rescue_suggestion', 'recent_search'], true) + ? $this->queryForTag($targetTag) + : null; + + $rows[] = [ + 'event_date' => $date->toDateString(), + 'event_type' => 'click', + 'surface' => $surface, + 'user_id' => null, + 'session_key' => hash('sha256', 'demo-' . $date->toDateString() . '-' . $index . '-' . $surface), + 'tag_slug' => $targetTag->slug, + 'source_tag_slug' => in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true) + ? $sourceTag->slug + : null, + 'query' => $query, + 'position' => random_int(1, 4), + 'meta' => json_encode([ + 'seeded_demo' => true, + 'seeded_at' => $now->toISOString(), + ], JSON_THROW_ON_ERROR), + 'occurred_at' => $date->copy()->setTime(random_int(8, 23), random_int(0, 59), random_int(0, 59)), + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + foreach (array_chunk($rows, 250) as $chunk) { + DB::table('tag_interaction_events')->insert($chunk); + } + + $this->call('analytics:aggregate-tag-interactions', ['--date' => $date->toDateString()]); + } + + $this->info("Seeded demo tag interaction events for the last {$days} days."); + + return self::SUCCESS; + } + + private function buildTransitionMap(Collection $tags): array + { + $pairs = DB::table('artwork_tag as source_pivot') + ->join('tags as source_tag', 'source_tag.id', '=', 'source_pivot.tag_id') + ->join('artwork_tag as target_pivot', 'target_pivot.artwork_id', '=', 'source_pivot.artwork_id') + ->join('tags as target_tag', 'target_tag.id', '=', 'target_pivot.tag_id') + ->whereIn('source_tag.id', $tags->pluck('id')->all()) + ->whereIn('target_tag.id', $tags->pluck('id')->all()) + ->whereColumn('source_tag.id', '!=', 'target_tag.id') + ->groupBy('source_tag.slug', 'target_tag.slug') + ->orderByRaw('COUNT(*) DESC') + ->get([ + 'source_tag.slug as source_slug', + 'target_tag.slug as target_slug', + DB::raw('COUNT(*) as pair_count'), + ]); + + $map = []; + foreach ($pairs as $pair) { + $map[$pair->source_slug][] = $pair->target_slug; + } + + return $map; + } + + private function pickSurface(): string + { + $roll = random_int(1, 100); + + return match (true) { + $roll <= 32 => 'search_suggestion', + $roll <= 46 => 'rescue_suggestion', + $roll <= 58 => 'recent_search', + $roll <= 80 => 'related_chip', + $roll <= 94 => 'related_cluster', + default => 'top_companion', + }; + } + + private function pickTargetTag(string $surface, string $sourceSlug, array $transitions, Collection $tags): object + { + if (in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)) { + $candidateSlugs = $transitions[$sourceSlug] ?? []; + if ($candidateSlugs !== []) { + $slug = $candidateSlugs[array_rand($candidateSlugs)]; + return $tags->firstWhere('slug', $slug) ?? $tags->where('slug', '!=', $sourceSlug)->random(); + } + + return $tags->where('slug', '!=', $sourceSlug)->random(); + } + + return $tags->random(); + } + + private function queryForTag(object $tag): string + { + $name = trim((string) ($tag->name ?? $tag->slug)); + $options = array_values(array_filter([ + strtolower($name), + strtolower((string) ($tag->slug ?? '')), + strtolower(substr($name, 0, max(3, min(strlen($name), 7)))), + ])); + + return $options[array_rand($options)]; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index b3be596b..41ea6fc2 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -9,6 +9,8 @@ use App\Console\Commands\MigrateFeaturedWorks; use App\Console\Commands\BackfillArtworkEmbeddingsCommand; use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand; use App\Console\Commands\AggregateFeedAnalyticsCommand; +use App\Console\Commands\AggregateTagInteractionAnalyticsCommand; +use App\Console\Commands\SeedTagInteractionDemoCommand; use App\Console\Commands\EvaluateFeedWeightsCommand; use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\CompareFeedAbCommand; @@ -41,6 +43,8 @@ class Kernel extends ConsoleKernel BackfillArtworkEmbeddingsCommand::class, AggregateSimilarArtworkAnalyticsCommand::class, AggregateFeedAnalyticsCommand::class, + AggregateTagInteractionAnalyticsCommand::class, + SeedTagInteractionDemoCommand::class, EvaluateFeedWeightsCommand::class, CompareFeedAbCommand::class, AiTagArtworksCommand::class, @@ -66,6 +70,7 @@ class Kernel extends ConsoleKernel ->runInBackground(); $schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10'); $schedule->command('analytics:aggregate-feed')->dailyAt('03:20'); + $schedule->command('analytics:aggregate-tag-interactions')->dailyAt('03:30'); // Recalculate trending scores every 30 minutes (staggered to reduce peak load) $schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes(); $schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground(); diff --git a/app/Http/Controllers/Admin/TagInteractionReportController.php b/app/Http/Controllers/Admin/TagInteractionReportController.php new file mode 100644 index 00000000..e385e833 --- /dev/null +++ b/app/Http/Controllers/Admin/TagInteractionReportController.php @@ -0,0 +1,47 @@ +validate([ + 'from' => ['nullable', 'date_format:Y-m-d'], + 'to' => ['nullable', 'date_format:Y-m-d'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString()); + $to = (string) ($validated['to'] ?? now()->toDateString()); + $limit = (int) ($validated['limit'] ?? 15); + + abort_if($from > $to, 422, 'Invalid date range.'); + + $report = $this->reportService->buildReport($from, $to, $limit); + + return view('admin.reports.tags', [ + 'filters' => [ + 'from' => $from, + 'to' => $to, + 'limit' => $limit, + ], + 'overview' => $report['overview'], + 'dailyClicks' => $report['daily_clicks'], + 'bySurface' => $report['by_surface'], + 'topTags' => $report['top_tags'], + 'topQueries' => $report['top_queries'], + 'topTransitions' => $report['top_transitions'], + 'latestAggregatedDate' => $report['latest_aggregated_date'], + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/TagInteractionReportController.php b/app/Http/Controllers/Api/Admin/TagInteractionReportController.php new file mode 100644 index 00000000..5afea252 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/TagInteractionReportController.php @@ -0,0 +1,53 @@ +validate([ + 'from' => ['nullable', 'date_format:Y-m-d'], + 'to' => ['nullable', 'date_format:Y-m-d'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString()); + $to = (string) ($validated['to'] ?? now()->toDateString()); + $limit = (int) ($validated['limit'] ?? 15); + + if ($from > $to) { + return response()->json([ + 'message' => 'Invalid date range: from must be before or equal to to.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $report = $this->reportService->buildReport($from, $to, $limit); + + return response()->json([ + 'meta' => [ + 'from' => $from, + 'to' => $to, + 'limit' => $limit, + 'generated_at' => now()->toISOString(), + 'latest_aggregated_date' => $report['latest_aggregated_date'], + ], + 'overview' => $report['overview'], + 'daily_clicks' => $report['daily_clicks'], + 'by_surface' => $report['by_surface'], + 'top_tags' => $report['top_tags'], + 'top_queries' => $report['top_queries'], + 'top_transitions' => $report['top_transitions'], + ], Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/Api/SuggestedTagsController.php b/app/Http/Controllers/Api/SuggestedTagsController.php index 908f31b8..90896b15 100644 --- a/app/Http/Controllers/Api/SuggestedTagsController.php +++ b/app/Http/Controllers/Api/SuggestedTagsController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Services\Recommendation\UserPreferenceBuilder; +use App\Services\Tags\TagDiscoveryService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -30,7 +31,10 @@ final class SuggestedTagsController extends Controller { private const LIMIT = 20; - public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {} + public function __construct( + private readonly UserPreferenceBuilder $prefBuilder, + private readonly TagDiscoveryService $tagDiscoveryService, + ) {} public function __invoke(Request $request): JsonResponse { @@ -126,27 +130,18 @@ final class SuggestedTagsController extends Controller } /** - * Aggregate tag usage over the last 7 days as a proxy for trend score. - * Uses artwork_tag + artworks.published_at to avoid a heavy events table dependency. - * * @return \Illuminate\Support\Collection */ private function trendingTags(int $limit): \Illuminate\Support\Collection { - $since = now()->subDays(7); - - return DB::table('artwork_tag as at') - ->join('tags as t', 't.id', '=', 'at.tag_id') - ->join('artworks as a', 'a.id', '=', 'at.artwork_id') - ->where('a.published_at', '>=', $since) - ->where('a.is_public', true) - ->where('a.is_approved', true) - ->whereNull('a.deleted_at') - ->where('t.is_active', true) - ->selectRaw('t.slug, COUNT(*) / 1.0 as trend_score') - ->groupBy('t.id', 't.slug') - ->orderByDesc('trend_score') - ->limit($limit) - ->get(); + return $this->tagDiscoveryService + ->popularTags($limit, 7) + ->map(static fn ($tag) => (object) [ + 'slug' => (string) $tag->slug, + 'trend_score' => max( + (float) ($tag->recent_clicks ?? 0), + (float) ($tag->usage_count ?? 0) / 1000 + ), + ]); } } diff --git a/app/Http/Controllers/Api/TagController.php b/app/Http/Controllers/Api/TagController.php index bd0a2a81..0fb5c233 100644 --- a/app/Http/Controllers/Api/TagController.php +++ b/app/Http/Controllers/Api/TagController.php @@ -7,33 +7,24 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\Tags\PopularTagsRequest; use App\Http\Requests\Tags\TagSearchRequest; -use App\Models\Tag; +use App\Services\Tags\TagDiscoveryService; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Cache; final class TagController extends Controller { + public function __construct(private readonly TagDiscoveryService $tagDiscoveryService) {} + public function search(TagSearchRequest $request): JsonResponse { $q = trim((string) ($request->validated()['q'] ?? '')); // Short results cached for 2 min; empty-query (popular suggestions) for 5 min. $ttl = $q === '' ? 300 : 120; - $cacheKey = 'tags.search.' . ($q === '' ? '__empty__' : md5($q)); + $cacheKey = 'tags.search.v2.' . ($q === '' ? '__empty__' : md5($q)); $data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed { - $query = Tag::query()->where('is_active', true); - if ($q !== '') { - $query->where(function ($sub) use ($q): void { - $sub->where('name', 'like', $q . '%') - ->orWhere('slug', 'like', $q . '%'); - }); - } - - return $query - ->orderByDesc('usage_count') - ->limit(20) - ->get(['id', 'name', 'slug', 'usage_count']); + return $this->tagDiscoveryService->searchSuggestions($q, 20); }); return response()->json(['data' => $data]); @@ -42,14 +33,10 @@ final class TagController extends Controller public function popular(PopularTagsRequest $request): JsonResponse { $limit = (int) ($request->validated()['limit'] ?? 20); - $cacheKey = 'tags.popular.' . $limit; + $cacheKey = 'tags.popular.v2.' . $limit; $data = Cache::remember($cacheKey, 300, function () use ($limit): mixed { - return Tag::query() - ->where('is_active', true) - ->orderByDesc('usage_count') - ->limit($limit) - ->get(['id', 'name', 'slug', 'usage_count']); + return $this->tagDiscoveryService->popularTags($limit); }); return response()->json(['data' => $data]); diff --git a/app/Http/Controllers/Api/TagInteractionAnalyticsController.php b/app/Http/Controllers/Api/TagInteractionAnalyticsController.php new file mode 100644 index 00000000..b4844147 --- /dev/null +++ b/app/Http/Controllers/Api/TagInteractionAnalyticsController.php @@ -0,0 +1,60 @@ +validate([ + 'event_type' => ['required', 'string', 'in:click'], + 'surface' => ['required', 'string', 'in:search_suggestion,recent_search,rescue_suggestion,related_chip,related_cluster,top_companion'], + 'tag_slug' => ['nullable', 'string', 'max:120'], + 'source_tag_slug' => ['nullable', 'string', 'max:120'], + 'query' => ['nullable', 'string', 'max:120'], + 'position' => ['nullable', 'integer', 'min:1', 'max:50'], + 'occurred_at' => ['nullable', 'date'], + 'meta' => ['nullable', 'array'], + ]); + + if (($payload['surface'] ?? null) !== 'recent_search' && empty($payload['tag_slug'])) { + return response()->json([ + 'message' => 'The selected analytics surface requires a tag slug.', + 'errors' => ['tag_slug' => ['The tag slug field is required for this surface.']], + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $occurredAt = isset($payload['occurred_at']) + ? CarbonImmutable::parse((string) $payload['occurred_at']) + : CarbonImmutable::now(); + $sessionId = $request->hasSession() ? (string) $request->session()->getId() : ''; + $sessionKey = $sessionId !== '' ? hash('sha256', $sessionId) : null; + + DB::table('tag_interaction_events')->insert([ + 'event_date' => $occurredAt->toDateString(), + 'event_type' => (string) $payload['event_type'], + 'surface' => (string) $payload['surface'], + 'user_id' => $request->user()?->id, + 'session_key' => $sessionKey, + 'tag_slug' => isset($payload['tag_slug']) ? (string) $payload['tag_slug'] : null, + 'source_tag_slug' => isset($payload['source_tag_slug']) ? (string) $payload['source_tag_slug'] : null, + 'query' => isset($payload['query']) ? trim((string) $payload['query']) : null, + 'position' => isset($payload['position']) ? (int) $payload['position'] : null, + 'meta' => $payload['meta'] ?? null, + 'occurred_at' => $occurredAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return response()->json(['success' => true], Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php index 63154444..0565ab48 100644 --- a/app/Http/Controllers/Studio/StudioArtworksApiController.php +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -10,6 +10,7 @@ use App\Services\ArtworkSearchIndexer; use App\Services\ArtworkVersioningService; use App\Services\Studio\StudioArtworkQueryService; use App\Services\Studio\StudioBulkActionService; +use App\Services\Tags\TagDiscoveryService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -27,6 +28,7 @@ final class StudioArtworksApiController extends Controller private readonly StudioBulkActionService $bulkService, private readonly ArtworkVersioningService $versioningService, private readonly ArtworkSearchIndexer $searchIndexer, + private readonly TagDiscoveryService $tagDiscoveryService, ) {} /** @@ -264,18 +266,13 @@ final class StudioArtworksApiController extends Controller /** * GET /api/studio/tags/search?q=... - * Search active tags by name for the bulk tag picker. + * Search active tags for studio pickers, with empty-query fallback to popular tags. */ public function searchTags(Request $request): JsonResponse { $query = trim((string) $request->input('q')); - $tags = \App\Models\Tag::query() - ->where('is_active', true) - ->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%")) - ->orderByDesc('usage_count') - ->limit(30) - ->get(['id', 'name', 'slug', 'usage_count']); + $tags = $this->tagDiscoveryService->searchSuggestions($query, 30); return response()->json($tags); } diff --git a/app/Services/Analytics/TagInteractionReportService.php b/app/Services/Analytics/TagInteractionReportService.php new file mode 100644 index 00000000..5c41edde --- /dev/null +++ b/app/Services/Analytics/TagInteractionReportService.php @@ -0,0 +1,191 @@ + $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; + } +} diff --git a/app/Services/Tags/TagDiscoveryService.php b/app/Services/Tags/TagDiscoveryService.php new file mode 100644 index 00000000..239d96aa --- /dev/null +++ b/app/Services/Tags/TagDiscoveryService.php @@ -0,0 +1,215 @@ + + */ + 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(); + } +} diff --git a/database/migrations/2026_03_16_000001_create_tag_interaction_events_table.php b/database/migrations/2026_03_16_000001_create_tag_interaction_events_table.php new file mode 100644 index 00000000..8dc9cf35 --- /dev/null +++ b/database/migrations/2026_03_16_000001_create_tag_interaction_events_table.php @@ -0,0 +1,35 @@ +id(); + $table->date('event_date')->index(); + $table->string('event_type', 24)->index(); + $table->string('surface', 32)->index(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('session_key', 64)->nullable()->index(); + $table->string('tag_slug', 120)->nullable()->index(); + $table->string('source_tag_slug', 120)->nullable()->index(); + $table->string('query', 120)->nullable(); + $table->unsignedSmallInteger('position')->nullable(); + $table->json('meta')->nullable(); + $table->timestamp('occurred_at')->nullable()->index(); + $table->timestamps(); + + $table->index(['event_date', 'surface', 'event_type'], 'tag_interaction_events_daily_idx'); + $table->index(['tag_slug', 'surface', 'event_date'], 'tag_interaction_events_tag_surface_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tag_interaction_events'); + } +}; diff --git a/database/migrations/2026_03_17_000001_create_tag_interaction_daily_metrics_table.php b/database/migrations/2026_03_17_000001_create_tag_interaction_daily_metrics_table.php new file mode 100644 index 00000000..59e3f4ae --- /dev/null +++ b/database/migrations/2026_03_17_000001_create_tag_interaction_daily_metrics_table.php @@ -0,0 +1,38 @@ +id(); + $table->date('metric_date'); + $table->string('surface', 32); + $table->string('tag_slug', 120)->default(''); + $table->string('source_tag_slug', 120)->default(''); + $table->string('query', 120)->default(''); + $table->unsignedInteger('clicks')->default(0); + $table->unsignedInteger('unique_users')->default(0); + $table->unsignedInteger('unique_sessions')->default(0); + $table->decimal('avg_position', 8, 2)->default(0); + $table->timestamps(); + + $table->unique( + ['metric_date', 'surface', 'tag_slug', 'source_tag_slug', 'query'], + 'tag_interaction_daily_metrics_unique_idx' + ); + $table->index(['metric_date', 'surface'], 'tag_interaction_daily_metrics_surface_idx'); + $table->index(['metric_date', 'tag_slug'], 'tag_interaction_daily_metrics_tag_idx'); + $table->index(['metric_date', 'query'], 'tag_interaction_daily_metrics_query_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tag_interaction_daily_metrics'); + } +}; diff --git a/resources/js/components/Studio/BulkTagModal.jsx b/resources/js/components/Studio/BulkTagModal.jsx index 59b0e07a..4a2d72b3 100644 --- a/resources/js/components/Studio/BulkTagModal.jsx +++ b/resources/js/components/Studio/BulkTagModal.jsx @@ -154,6 +154,8 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm }) {!loading && results.map((tag) => { const isSelected = selected.some((t) => t.id === tag.id) + const recentClicks = Number(tag.recent_clicks || 0) + const usageCount = Number(tag.usage_count || 0) return ( ) })} diff --git a/resources/js/components/Studio/BulkTagModal.test.jsx b/resources/js/components/Studio/BulkTagModal.test.jsx new file mode 100644 index 00000000..238958a8 --- /dev/null +++ b/resources/js/components/Studio/BulkTagModal.test.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import BulkTagModal from './BulkTagModal' + +describe('BulkTagModal', () => { + beforeEach(() => { + document.head.innerHTML = '' + + global.fetch = vi.fn(async (url) => { + const requestUrl = String(url) + + if (requestUrl.includes('?q=high')) { + return { + json: async () => ([ + { id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 }, + { id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 }, + ]), + } + } + + return { + json: async () => ([ + { id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 }, + ]), + } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.head.innerHTML = '' + }) + + it('shows recent click momentum for initial results', async () => { + render( {}} onConfirm={() => {}} />) + + await waitFor(() => { + expect(screen.getByText('Popular Pick')).not.toBeNull() + }) + + expect(screen.getByText('9 recent clicks')).not.toBeNull() + }) + + it('returns selected tag ids and shows recent click momentum in search results', async () => { + const onConfirm = vi.fn() + + render( {}} onConfirm={onConfirm} />) + + const input = screen.getByPlaceholderText('Search tags…') + await userEvent.type(input, 'high') + + await waitFor(() => { + expect(screen.getByText('18 recent clicks')).not.toBeNull() + }) + + await userEvent.click(screen.getByRole('button', { name: /High Contrast/i })) + await userEvent.click(screen.getByRole('button', { name: /Add 1 tag/i })) + + expect(onConfirm).toHaveBeenCalledWith([2]) + }) +}) \ No newline at end of file diff --git a/resources/js/components/tags/TagInput.jsx b/resources/js/components/tags/TagInput.jsx index f8fcbf29..59841b29 100644 --- a/resources/js/components/tags/TagInput.jsx +++ b/resources/js/components/tags/TagInput.jsx @@ -52,7 +52,8 @@ function toSuggestionItems(raw) { key: item?.id ?? tag, label: item?.name || item?.tag || item?.slug || tag, tag, - usageCount: typeof item?.usage_count === 'number' ? item.usage_count : null, + usageCount: Number.isFinite(Number(item?.usage_count)) ? Number(item.usage_count) : null, + recentClicks: Number.isFinite(Number(item?.recent_clicks)) ? Number(item.recent_clicks) : 0, isAi: Boolean(item?.is_ai || item?.source === 'ai'), } }) @@ -173,6 +174,9 @@ function SuggestionDropdown({ {!loading && !error && suggestions.map((item, index) => { const active = highlightedIndex === index + const detailLabel = item.recentClicks > 0 + ? `${item.recentClicks.toLocaleString()} recent` + : (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null) return (
  • )} - {typeof item.usageCount === 'number' && ( - {item.usageCount} + {detailLabel && ( + {detailLabel} )}
  • ) diff --git a/resources/js/components/tags/TagInput.test.jsx b/resources/js/components/tags/TagInput.test.jsx index 263702fd..3d2412f6 100644 --- a/resources/js/components/tags/TagInput.test.jsx +++ b/resources/js/components/tags/TagInput.test.jsx @@ -26,8 +26,8 @@ describe('TagInput', () => { return { data: { data: [ - { id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 }, - { id: 2, name: 'city', slug: 'city', usage_count: 30 }, + { id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10, recent_clicks: 2 }, + { id: 2, name: 'city', slug: 'city', usage_count: 30, recent_clicks: 14 }, ], }, } @@ -74,6 +74,17 @@ describe('TagInput', () => { expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull() }) + it('shows recent click momentum when suggestions provide it', async () => { + render() + + const input = screen.getByLabelText('Tag input') + await userEvent.type(input, 'city') + + await waitFor(() => { + expect(screen.getByText('14 recent')).not.toBeNull() + }) + }) + it('supports comma-separated paste', async () => { render() diff --git a/resources/js/components/tags/TagPicker.jsx b/resources/js/components/tags/TagPicker.jsx index f9591706..e80a0a14 100644 --- a/resources/js/components/tags/TagPicker.jsx +++ b/resources/js/components/tags/TagPicker.jsx @@ -37,15 +37,18 @@ function toListItem(item) { if (!item) return null if (typeof item === 'string') { const slug = normalizeSlug(item) - return slug ? { key: slug, slug, name: slug, usageCount: null, isAi: false } : null + return slug ? { key: slug, slug, name: slug, usageCount: null, recentClicks: 0, isAi: false } : null } const slug = normalizeSlug(item.slug || item.tag || item.name || '') if (!slug) return null + const usageCount = Number(item.usage_count) + const recentClicks = Number(item.recent_clicks) return { key: String(item.id ?? slug), slug, name: item.name || item.tag || item.slug || slug, - usageCount: typeof item.usage_count === 'number' ? item.usage_count : null, + usageCount: Number.isFinite(usageCount) ? usageCount : null, + recentClicks: Number.isFinite(recentClicks) ? recentClicks : 0, isAi: Boolean(item.is_ai || item.source === 'ai'), } } @@ -123,6 +126,10 @@ function AddNewRow({ label, onAdd, disabled }) { } function ListRow({ item, isSelected, onToggle, disabled }) { + const detailLabel = item.recentClicks > 0 + ? `${item.recentClicks.toLocaleString()} recent clicks` + : (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null) + return ( diff --git a/resources/js/components/tags/TagPicker.test.jsx b/resources/js/components/tags/TagPicker.test.jsx new file mode 100644 index 00000000..983b536e --- /dev/null +++ b/resources/js/components/tags/TagPicker.test.jsx @@ -0,0 +1,76 @@ +import React from 'react' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import TagPicker from './TagPicker' + +function Harness({ initial = [] }) { + const [tags, setTags] = React.useState(initial) + + return ( + + ) +} + +describe('TagPicker', () => { + beforeEach(() => { + window.axios = { + get: vi.fn(async (url) => { + if (url.startsWith('/api/tags/search')) { + return { + data: { + data: [ + { id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 }, + { id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 }, + ], + }, + } + } + + return { + data: { + data: [ + { id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 }, + ], + }, + } + }), + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows recent click momentum for popular tags on mount', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Popular Pick')).not.toBeNull() + }) + + expect(screen.getByText('9 recent clicks')).not.toBeNull() + }) + + it('shows recent click momentum in search results and lets the user select a tag', async () => { + render() + + const input = screen.getByLabelText('Search or add tags') + await userEvent.type(input, 'high') + + await waitFor(() => { + expect(screen.getByText('18 recent clicks')).not.toBeNull() + }) + + await userEvent.click(screen.getByRole('button', { name: /High Contrast/i })) + + expect(screen.getByText('High Contrast')).not.toBeNull() + expect(screen.getByRole('button', { name: 'Remove tag High Contrast' })).not.toBeNull() + }) +}) \ No newline at end of file diff --git a/resources/js/lib/tagAnalytics.js b/resources/js/lib/tagAnalytics.js new file mode 100644 index 00000000..3112a6ab --- /dev/null +++ b/resources/js/lib/tagAnalytics.js @@ -0,0 +1,19 @@ +export function sendTagInteractionEvent(payload) { + const endpoint = '/api/analytics/tags' + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' + const body = JSON.stringify(payload) + + fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}), + }, + body, + keepalive: true, + credentials: 'same-origin', + }).catch(() => { + }) +} diff --git a/resources/js/nova.js b/resources/js/nova.js index b8d7e876..792b3b2a 100644 --- a/resources/js/nova.js +++ b/resources/js/nova.js @@ -14,6 +14,7 @@ if (!window.Alpine) { // Gallery navigation context: stores artwork list for prev/next on artwork page import './lib/nav-context.js'; +import { sendTagInteractionEvent } from './lib/tagAnalytics'; function mountStoryEditor() { var storyEditorRoot = document.getElementById('story-editor-react-root'); @@ -61,6 +62,475 @@ function mountStoryEditor() { mountStoryEditor(); +function initTagsSearchAssist() { + var roots = document.querySelectorAll('[data-tags-search-root]'); + if (!roots.length) return; + + var recentSearchesKey = 'skinbase.tags.recent-searches'; + + function findClosest(el, selector) { + while (el && el.nodeType === 1) { + if (el.matches(selector)) return el; + el = el.parentElement; + } + return null; + } + + function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + roots.forEach(function (root, rootIndex) { + var form = findClosest(root, '[data-tags-search-form]'); + var input = root.querySelector('[data-tags-search-input]'); + var panel = root.querySelector('[data-tags-search-panel]'); + var title = root.querySelector('[data-tags-search-title]'); + var results = root.querySelector('[data-tags-search-results]'); + var endpoint = root.getAttribute('data-search-endpoint') || '/api/tags/search'; + var popularEndpoint = root.getAttribute('data-popular-endpoint') || '/api/tags/popular'; + var optionIdPrefix = 'tags-search-option-' + rootIndex + '-'; + var debounceTimer = null; + var abortController = null; + var latestQuery = ''; + var activeIndex = -1; + + if (!input || !panel || !results) return; + + function readRecentSearches() { + try { + var parsed = JSON.parse(window.localStorage.getItem(recentSearchesKey) || '[]'); + return Array.isArray(parsed) ? parsed.filter(Boolean).slice(0, 5) : []; + } catch (_error) { + return []; + } + } + + function writeRecentSearch(query) { + var normalized = (query || '').trim(); + if (!normalized) return; + + var next = readRecentSearches().filter(function (item) { + return item.toLowerCase() !== normalized.toLowerCase(); + }); + + next.unshift(normalized); + + try { + window.localStorage.setItem(recentSearchesKey, JSON.stringify(next.slice(0, 5))); + } catch (_error) { + // Ignore storage failures silently. + } + } + + function clearRecentSearches() { + try { + window.localStorage.removeItem(recentSearchesKey); + } catch (_error) { + // Ignore storage failures silently. + } + } + + function removeRecentSearch(query) { + var normalized = (query || '').trim().toLowerCase(); + if (!normalized) return; + + var next = readRecentSearches().filter(function (item) { + return item.toLowerCase() !== normalized; + }); + + try { + window.localStorage.setItem(recentSearchesKey, JSON.stringify(next)); + } catch (_error) { + // Ignore storage failures silently. + } + } + + function getItems() { + return Array.prototype.slice.call(results.querySelectorAll('[data-tags-search-item]')); + } + + function setActiveItem(nextIndex) { + var items = getItems(); + activeIndex = nextIndex; + + items.forEach(function (item, index) { + var active = index === nextIndex; + item.classList.toggle('bg-white/[0.06]', active); + item.classList.toggle('text-white', active); + item.setAttribute('aria-selected', active ? 'true' : 'false'); + if (!item.id) { + item.id = optionIdPrefix + index; + } + if (active) { + item.scrollIntoView({ block: 'nearest' }); + } + }); + + if (nextIndex >= 0 && items[nextIndex]) { + input.setAttribute('aria-activedescendant', items[nextIndex].id); + } else { + input.removeAttribute('aria-activedescendant'); + } + } + + function focusItem(nextIndex) { + var items = getItems(); + if (!items.length) return; + + var boundedIndex = Math.max(0, Math.min(nextIndex, items.length - 1)); + setActiveItem(boundedIndex); + items[boundedIndex].focus(); + } + + function setExpanded(expanded) { + input.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + panel.classList.toggle('hidden', !expanded); + if (!expanded) { + activeIndex = -1; + setActiveItem(-1); + } + } + + function clearResults() { + results.innerHTML = ''; + activeIndex = -1; + } + + function hidePanel() { + setExpanded(false); + } + + function renderLoadingState(query) { + title.textContent = query ? 'Searching tags' : 'Loading suggestions'; + results.setAttribute('aria-busy', 'true'); + results.innerHTML = '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    '; + setExpanded(true); + setActiveItem(-1); + } + + function fetchJson(url, signal) { + return fetch(url, { + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, + signal: signal, + }).then(function (response) { + if (!response.ok) throw new Error('Failed to load tag suggestions'); + return response.json(); + }); + } + + function renderRecentSearches(items) { + if (!items.length) return ''; + + return '
    ' + + '
    ' + + '
    Recent searches
    ' + + '' + + '
    ' + + '
    ' + + items.map(function (item) { + var encoded = encodeURIComponent(item); + var escaped = escapeHtml(item); + return '' + + '' + escaped + '' + + '' + + ''; + }).join('') + + '
    ' + + '
    '; + } + + function renderRescueSuggestions(items, query) { + if (!items.length) return ''; + + return '
    ' + + '
    Try these instead
    ' + + '
    ' + + items.map(function (item, index) { + var name = escapeHtml(item.name || ''); + var slug = escapeHtml(item.slug || ''); + return '' + + '' + name + '#' + slug + '' + + 'Popular' + + ''; + }).join('') + + '
    ' + + '' + + '
    '; + } + + function renderNoMatchState(query, rescueItems) { + title.textContent = 'No matching tags'; + results.setAttribute('aria-busy', 'false'); + results.innerHTML = '
    No direct matches for ' + escapeHtml(query) + '. Try a broader keyword or jump into one of these popular tags.
    ' + + renderRescueSuggestions(rescueItems || [], query); + setExpanded(true); + setActiveItem(-1); + } + + function renderItems(items, query) { + clearResults(); + results.setAttribute('aria-busy', 'false'); + + var recentSearches = !query ? readRecentSearches() : []; + + if (!items.length && recentSearches.length) { + title.textContent = 'Recent searches'; + results.innerHTML = renderRecentSearches(recentSearches); + setExpanded(true); + setActiveItem(-1); + return; + } + + if (!items.length) { + return; + } + + title.textContent = query ? 'Matching tags' : (recentSearches.length ? 'Recent and popular' : 'Popular tags'); + + if (recentSearches.length) { + results.insertAdjacentHTML('beforeend', renderRecentSearches(recentSearches)); + } + + items.forEach(function (item, index) { + var link = document.createElement('a'); + var itemName = escapeHtml(item.name || ''); + var itemSlug = escapeHtml(item.slug || ''); + var recentClicks = Number(item.recent_clicks || 0); + var metricLabel = recentClicks > 0 + ? recentClicks.toLocaleString() + ' recent' + : Number(item.usage_count || 0).toLocaleString() + ' uses'; + link.href = '/tag/' + item.slug; + link.className = 'flex items-center justify-between gap-3 rounded-xl px-3 py-3 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white'; + link.setAttribute('data-tags-search-item', ''); + link.setAttribute('data-tags-search-surface', 'search_suggestion'); + link.setAttribute('data-tags-search-tag', item.slug || ''); + link.setAttribute('data-tags-search-query', item.name || item.slug || ''); + link.setAttribute('data-tags-search-input', query || ''); + link.setAttribute('data-tags-search-position', String(index + 1)); + link.setAttribute('aria-selected', 'false'); + link.setAttribute('role', 'option'); + link.tabIndex = -1; + link.innerHTML = + '' + + '#' + + '' + itemName + '#' + itemSlug + '' + + '' + + '' + metricLabel + ''; + results.appendChild(link); + }); + + setExpanded(true); + setActiveItem(-1); + } + + function fetchSuggestions(query) { + if (abortController) abortController.abort(); + abortController = new AbortController(); + latestQuery = query; + renderLoadingState(query); + + var params = new URLSearchParams(); + if (query) params.set('q', query); + + fetchJson(endpoint + (params.toString() ? ('?' + params.toString()) : ''), abortController.signal) + .then(function (payload) { + if (input.value.trim() !== latestQuery) return; + var items = Array.isArray(payload.data) ? payload.data.slice(0, 8) : []; + if (items.length || query === '') { + renderItems(items, query); + return; + } + + var popularParams = new URLSearchParams(); + popularParams.set('limit', '4'); + + return fetchJson(popularEndpoint + '?' + popularParams.toString(), abortController.signal) + .then(function (popularPayload) { + if (input.value.trim() !== latestQuery) return; + renderNoMatchState(query, Array.isArray(popularPayload.data) ? popularPayload.data : []); + }); + }) + .catch(function (error) { + if (error && error.name === 'AbortError') return; + hidePanel(); + }); + } + + input.addEventListener('focus', function () { + fetchSuggestions(input.value.trim()); + }); + + input.addEventListener('input', function () { + var query = input.value.trim(); + window.clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(function () { + fetchSuggestions(query); + }, 180); + }); + + input.addEventListener('keydown', function (event) { + var items = getItems(); + + if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && items.length) { + event.preventDefault(); + if (event.key === 'ArrowDown') { + setActiveItem(activeIndex < 0 ? 0 : Math.min(activeIndex + 1, items.length - 1)); + } else { + setActiveItem(activeIndex < 0 ? items.length - 1 : Math.max(activeIndex - 1, 0)); + } + return; + } + + if (event.key === 'Enter' && activeIndex >= 0 && items[activeIndex]) { + event.preventDefault(); + writeRecentSearch(items[activeIndex].getAttribute('data-tags-search-query') || input.value); + window.location.href = items[activeIndex].href; + return; + } + + if (event.key === 'Escape') { + hidePanel(); + } + }); + + results.addEventListener('keydown', function (event) { + var items = getItems(); + var currentIndex = items.indexOf(document.activeElement); + + if (event.key === 'ArrowDown' && items.length) { + event.preventDefault(); + focusItem(currentIndex + 1); + return; + } + + if (event.key === 'ArrowUp' && items.length) { + event.preventDefault(); + if (currentIndex <= 0) { + setActiveItem(-1); + input.focus(); + } else { + focusItem(currentIndex - 1); + } + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + hidePanel(); + input.focus(); + } + }); + + results.addEventListener('mouseover', function (event) { + var item = findClosest(event.target, '[data-tags-search-item]'); + if (!item) return; + var items = getItems(); + setActiveItem(items.indexOf(item)); + }); + + results.addEventListener('click', function (event) { + var clearButton = findClosest(event.target, '[data-tags-search-clear-recent]'); + if (clearButton) { + event.preventDefault(); + clearRecentSearches(); + fetchSuggestions(input.value.trim()); + return; + } + + var removeButton = findClosest(event.target, '[data-tags-search-remove-recent]'); + if (removeButton) { + event.preventDefault(); + removeRecentSearch(removeButton.getAttribute('data-tags-search-query') || ''); + fetchSuggestions(input.value.trim()); + return; + } + + var item = findClosest(event.target, '[data-tags-search-item]'); + if (!item) return; + var query = item.getAttribute('data-tags-search-query') || item.textContent || ''; + writeRecentSearch(query); + + var surface = item.getAttribute('data-tags-search-surface') || ''; + var tagSlug = item.getAttribute('data-tags-search-tag') || ''; + + if (surface && (tagSlug || surface === 'recent_search')) { + sendTagInteractionEvent({ + event_type: 'click', + surface: surface, + tag_slug: tagSlug || null, + query: item.getAttribute('data-tags-search-input') || item.getAttribute('data-tags-search-query') || input.value.trim() || null, + position: Number(item.getAttribute('data-tags-search-position') || 0) || null, + occurred_at: new Date().toISOString(), + }); + } + }); + + if (form) { + form.addEventListener('submit', function () { + writeRecentSearch(input.value); + }); + } + + document.addEventListener('click', function (event) { + if (!root.contains(event.target)) { + hidePanel(); + } + }); + }); +} + +initTagsSearchAssist(); + +function initTagAnalyticsLinks() { + document.addEventListener('click', function (event) { + var el = event.target; + + while (el && el.nodeType === 1) { + if (el.matches('[data-tag-analytics-link]')) { + var tagSlug = el.getAttribute('data-tag-analytics-tag') || ''; + var surface = el.getAttribute('data-tag-analytics-surface') || ''; + + if (tagSlug && surface) { + sendTagInteractionEvent({ + event_type: 'click', + surface: surface, + tag_slug: tagSlug, + source_tag_slug: el.getAttribute('data-tag-analytics-source-tag') || null, + position: Number(el.getAttribute('data-tag-analytics-position') || 0) || null, + occurred_at: new Date().toISOString(), + }); + } + + return; + } + + el = el.parentElement; + } + }); +} + +initTagAnalyticsLinks(); + (function () { function initBlurPreviewImages() { var selector = 'img[data-blur-preview]'; diff --git a/resources/views/admin/reports/queue.blade.php b/resources/views/admin/reports/queue.blade.php index 9375ceb1..f7c8c302 100644 --- a/resources/views/admin/reports/queue.blade.php +++ b/resources/views/admin/reports/queue.blade.php @@ -2,9 +2,23 @@ @section('content')
    -
    -

    Reports Queue

    -

    Use the API endpoint /api/reports to submit reports and review records in the reports table.

    +
    +
    +

    Reports Hub

    +

    Internal reporting entry points for moderation and discovery analytics.

    +
    + +
    + +

    Tag Interaction Report

    +

    Inspect top surfaces, tags, search terms, and related-tag transitions from the new tag analytics pipeline.

    +
    + +
    +

    Moderation Queue

    +

    Use the API endpoint /api/reports to submit reports and review records in the reports table.

    +
    +
    @endsection diff --git a/resources/views/admin/reports/tags.blade.php b/resources/views/admin/reports/tags.blade.php new file mode 100644 index 00000000..af7e3667 --- /dev/null +++ b/resources/views/admin/reports/tags.blade.php @@ -0,0 +1,216 @@ +@extends('layouts.nova.content-layout') + +@section('page-content') +
    +
    +
    +

    Tag Interaction Report

    +

    + Internal dashboard for tag discovery clicks. Use it to inspect surface performance, top tags, query demand, and tag-to-tag transitions for recommendation tuning. +

    +
    + +
    + +
    +
    + + + +
    + +
    +
    + +
    + Latest aggregated date: {{ $latestAggregatedDate ?? 'not aggregated yet' }} + Latest raw event: {{ $overview['latest_event_at'] ?? 'n/a' }} +
    + + @if(app()->environment('local')) +
    +

    Local demo data

    +

    + This report can be filled locally with seeded click data. Run + php artisan analytics:seed-tag-interaction-demo --days=14 --per-day=80 --refresh + and refresh this page to inspect realistic search, recommendation, and transition metrics. +

    +
    + @endif +
    + +
    +
    +

    Total clicks

    +

    {{ number_format($overview['total_clicks']) }}

    +
    +
    +

    Unique users

    +

    {{ number_format($overview['unique_users']) }}

    +
    +
    +

    Unique sessions

    +

    {{ number_format($overview['unique_sessions']) }}

    +
    +
    +

    Distinct tags

    +

    {{ number_format($overview['distinct_tags']) }}

    +
    +
    + +
    +
    +
    +

    Daily Click Trend

    +

    Daily rollups for tuning trending and recommendation decisions.

    +
    +
    +
    + @forelse($dailyClicks as $row) +
    +

    {{ $row['date'] }}

    +

    {{ number_format($row['clicks']) }}

    +

    clicks

    +
    + @empty +

    No aggregated rows available for the selected range yet.

    + @endforelse +
    +
    + +
    +
    +

    Top Surfaces

    +
    + + + + + + + + + + + + @forelse($bySurface as $row) + + + + + + + + @empty + + @endforelse + +
    SurfaceClicksUsersSessionsAvg pos.
    {{ $row['surface'] }}{{ number_format($row['clicks']) }}{{ number_format($row['unique_users']) }}{{ number_format($row['unique_sessions']) }}{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}
    No surface data in this range.
    +
    +
    + +
    +

    Top Query Terms

    +
    + + + + + + + + + + + @forelse($topQueries as $row) + + + + + + + @empty + + @endforelse + +
    QueryClicksSessionsResolved tags
    {{ $row['query'] }}{{ number_format($row['clicks']) }}{{ number_format($row['unique_sessions']) }}{{ number_format($row['resolved_tags']) }}
    No query data in this range.
    +
    +
    +
    + +
    +
    +

    Top Tags

    +
    + + + + + + + + + + + + @forelse($topTags as $row) + + + + + + + + @empty + + @endforelse + +
    TagClicksRecommendationSearchSessions
    #{{ $row['tag_slug'] }}{{ number_format($row['clicks']) }}{{ number_format($row['recommendation_clicks']) }}{{ number_format($row['search_clicks']) }}{{ number_format($row['unique_sessions']) }}
    No tag click data in this range.
    +
    +
    + +
    +

    Top Tag Transitions

    +

    Most-clicked source tag to target tag paths from related-tag surfaces.

    +
    + + + + + + + + + + + + @forelse($topTransitions as $row) + + + + + + + + @empty + + @endforelse + +
    SourceTargetClicksSessionsAvg pos.
    #{{ $row['source_tag_slug'] }}#{{ $row['tag_slug'] }}{{ number_format($row['clicks']) }}{{ number_format($row['unique_sessions']) }}{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}
    No transition data in this range.
    +
    +
    +
    +
    +@endsection diff --git a/resources/views/components/nova-page-header.blade.php b/resources/views/components/nova-page-header.blade.php new file mode 100644 index 00000000..c9a12d7a --- /dev/null +++ b/resources/views/components/nova-page-header.blade.php @@ -0,0 +1,71 @@ +@props([ + 'section' => null, + 'title' => null, + 'icon' => null, + 'breadcrumbs' => collect(), + 'description' => null, + 'showSection' => true, + 'showIcon' => true, + 'showTitle' => true, + 'showBreadcrumbs' => true, + 'showDescription' => true, + 'headerClass' => '', + 'innerClass' => '', + 'contentClass' => '', + 'actionsClass' => '', + 'sectionClass' => '', + 'titleClass' => '', + 'iconClass' => '', + 'descriptionClass' => '', +]) + +@php + $headerBreadcrumbs = $breadcrumbs instanceof \Illuminate\Support\Collection + ? $breadcrumbs + : collect($breadcrumbs ?? []); + + $hasActions = isset($actions) && trim((string) $actions) !== ''; + $resolvedHeaderClass = trim('px-6 pt-10 pb-7 md:px-10 border-b border-white/[0.06] bg-gradient-to-b from-sky-500/[0.04] to-transparent ' . $headerClass); + $resolvedInnerClass = trim('flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between ' . $innerClass); + $resolvedContentClass = trim('max-w-3xl min-w-0 ' . $contentClass); + $resolvedActionsClass = trim('flex flex-col items-start gap-3 lg:items-end ' . $actionsClass); + $resolvedSectionClass = trim('text-xs font-semibold uppercase tracking-widest text-white/30 mb-1 ' . $sectionClass); + $resolvedTitleClass = trim('text-3xl font-bold text-white leading-tight flex items-center gap-3 ' . $titleClass); + $resolvedIconClass = trim('text-sky-400 text-2xl ' . $iconClass); + $resolvedDescriptionClass = trim('mt-1 text-sm text-white/50 ' . $descriptionClass); +@endphp + +
    class([$resolvedHeaderClass]) }}> +
    +
    + @if($showSection && filled($section)) +

    {{ $section }}

    + @endif + + @if($showTitle && filled($title)) +

    + @if($showIcon && filled($icon)) + + @endif + {{ $title }} +

    + @endif + + @if($showBreadcrumbs && $headerBreadcrumbs->isNotEmpty()) +
    + @include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs]) +
    + @endif + + @if($showDescription && filled($description)) +

    {!! $description !!}

    + @endif +
    + + @if($hasActions) +
    + {{ $actions }} +
    + @endif +
    +
    diff --git a/resources/views/gallery/_browse_nav.blade.php b/resources/views/gallery/_browse_nav.blade.php index 0d2a5001..7e941c05 100644 --- a/resources/views/gallery/_browse_nav.blade.php +++ b/resources/views/gallery/_browse_nav.blade.php @@ -6,6 +6,7 @@ @php $active = $section ?? 'artworks'; + $includeTags = (bool) ($includeTags ?? false); $sections = collect([ 'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'], @@ -14,6 +15,10 @@ 'skins' => ['label' => 'Skins', 'icon' => 'fa-layer-group', 'href' => '/skins'], 'other' => ['label' => 'Other', 'icon' => 'fa-folder-open', 'href' => '/other'], ]); + + if ($includeTags) { + $sections->put('tags', ['label' => 'Tags', 'icon' => 'fa-tags', 'href' => '/tags']); + } @endphp