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,72 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AggregateTagInteractionAnalyticsCommand extends Command
{
protected $signature = 'analytics:aggregate-tag-interactions {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate tag interaction analytics into daily metrics by surface, tag, source tag, and query';
public function handle(): int
{
$date = $this->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;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tag;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class SeedTagInteractionDemoCommand extends Command
{
protected $signature = 'analytics:seed-tag-interaction-demo
{--days=14 : Number of days to generate demo events for}
{--per-day=90 : Approximate number of demo events to write per day}
{--refresh : Remove existing seeded demo events first}
{--force : Allow running outside local/testing environments}';
protected $description = 'Generate demo tag interaction events for local analytics dashboards and ranking validation';
public function handle(): int
{
if (! app()->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)];
}
}

View File

@@ -9,6 +9,8 @@ use App\Console\Commands\MigrateFeaturedWorks;
use App\Console\Commands\BackfillArtworkEmbeddingsCommand; use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand; use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand; use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
use App\Console\Commands\SeedTagInteractionDemoCommand;
use App\Console\Commands\EvaluateFeedWeightsCommand; use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\CompareFeedAbCommand; use App\Console\Commands\CompareFeedAbCommand;
@@ -41,6 +43,8 @@ class Kernel extends ConsoleKernel
BackfillArtworkEmbeddingsCommand::class, BackfillArtworkEmbeddingsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class, AggregateSimilarArtworkAnalyticsCommand::class,
AggregateFeedAnalyticsCommand::class, AggregateFeedAnalyticsCommand::class,
AggregateTagInteractionAnalyticsCommand::class,
SeedTagInteractionDemoCommand::class,
EvaluateFeedWeightsCommand::class, EvaluateFeedWeightsCommand::class,
CompareFeedAbCommand::class, CompareFeedAbCommand::class,
AiTagArtworksCommand::class, AiTagArtworksCommand::class,
@@ -66,6 +70,7 @@ class Kernel extends ConsoleKernel
->runInBackground(); ->runInBackground();
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10'); $schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20'); $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) // 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=24h')->everyThirtyMinutes();
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground(); $schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\TagInteractionReportService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
final class TagInteractionReportController extends Controller
{
public function __construct(private readonly TagInteractionReportService $reportService) {}
public function index(Request $request): View
{
$validated = $request->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'],
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\TagInteractionReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class TagInteractionReportController extends Controller
{
public function __construct(private readonly TagInteractionReportService $reportService) {}
public function index(Request $request): JsonResponse
{
$validated = $request->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);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Recommendation\UserPreferenceBuilder; use App\Services\Recommendation\UserPreferenceBuilder;
use App\Services\Tags\TagDiscoveryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -30,7 +31,10 @@ final class SuggestedTagsController extends Controller
{ {
private const LIMIT = 20; 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 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<int, object{slug:string, trend_score:float}> * @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
*/ */
private function trendingTags(int $limit): \Illuminate\Support\Collection private function trendingTags(int $limit): \Illuminate\Support\Collection
{ {
$since = now()->subDays(7); return $this->tagDiscoveryService
->popularTags($limit, 7)
return DB::table('artwork_tag as at') ->map(static fn ($tag) => (object) [
->join('tags as t', 't.id', '=', 'at.tag_id') 'slug' => (string) $tag->slug,
->join('artworks as a', 'a.id', '=', 'at.artwork_id') 'trend_score' => max(
->where('a.published_at', '>=', $since) (float) ($tag->recent_clicks ?? 0),
->where('a.is_public', true) (float) ($tag->usage_count ?? 0) / 1000
->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();
} }
} }

View File

@@ -7,33 +7,24 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Tags\PopularTagsRequest; use App\Http\Requests\Tags\PopularTagsRequest;
use App\Http\Requests\Tags\TagSearchRequest; use App\Http\Requests\Tags\TagSearchRequest;
use App\Models\Tag; use App\Services\Tags\TagDiscoveryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
final class TagController extends Controller final class TagController extends Controller
{ {
public function __construct(private readonly TagDiscoveryService $tagDiscoveryService) {}
public function search(TagSearchRequest $request): JsonResponse public function search(TagSearchRequest $request): JsonResponse
{ {
$q = trim((string) ($request->validated()['q'] ?? '')); $q = trim((string) ($request->validated()['q'] ?? ''));
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min. // Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
$ttl = $q === '' ? 300 : 120; $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 { $data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
$query = Tag::query()->where('is_active', true); return $this->tagDiscoveryService->searchSuggestions($q, 20);
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 response()->json(['data' => $data]); return response()->json(['data' => $data]);
@@ -42,14 +33,10 @@ final class TagController extends Controller
public function popular(PopularTagsRequest $request): JsonResponse public function popular(PopularTagsRequest $request): JsonResponse
{ {
$limit = (int) ($request->validated()['limit'] ?? 20); $limit = (int) ($request->validated()['limit'] ?? 20);
$cacheKey = 'tags.popular.' . $limit; $cacheKey = 'tags.popular.v2.' . $limit;
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed { $data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
return Tag::query() return $this->tagDiscoveryService->popularTags($limit);
->where('is_active', true)
->orderByDesc('usage_count')
->limit($limit)
->get(['id', 'name', 'slug', 'usage_count']);
}); });
return response()->json(['data' => $data]); return response()->json(['data' => $data]);

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class TagInteractionAnalyticsController extends Controller
{
public function store(Request $request): JsonResponse
{
$payload = $request->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);
}
}

View File

@@ -10,6 +10,7 @@ use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkVersioningService; use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService; use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService; use App\Services\Studio\StudioBulkActionService;
use App\Services\Tags\TagDiscoveryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -27,6 +28,7 @@ final class StudioArtworksApiController extends Controller
private readonly StudioBulkActionService $bulkService, private readonly StudioBulkActionService $bulkService,
private readonly ArtworkVersioningService $versioningService, private readonly ArtworkVersioningService $versioningService,
private readonly ArtworkSearchIndexer $searchIndexer, private readonly ArtworkSearchIndexer $searchIndexer,
private readonly TagDiscoveryService $tagDiscoveryService,
) {} ) {}
/** /**
@@ -264,18 +266,13 @@ final class StudioArtworksApiController extends Controller
/** /**
* GET /api/studio/tags/search?q=... * 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 public function searchTags(Request $request): JsonResponse
{ {
$query = trim((string) $request->input('q')); $query = trim((string) $request->input('q'));
$tags = \App\Models\Tag::query() $tags = $this->tagDiscoveryService->searchSuggestions($query, 30);
->where('is_active', true)
->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%"))
->orderByDesc('usage_count')
->limit(30)
->get(['id', 'name', 'slug', 'usage_count']);
return response()->json($tags); return response()->json($tags);
} }

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();
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tag_interaction_events', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -154,6 +154,8 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
{!loading && {!loading &&
results.map((tag) => { results.map((tag) => {
const isSelected = selected.some((t) => t.id === tag.id) const isSelected = selected.some((t) => t.id === tag.id)
const recentClicks = Number(tag.recent_clicks || 0)
const usageCount = Number(tag.usage_count || 0)
return ( return (
<button <button
key={tag.id} key={tag.id}
@@ -174,7 +176,9 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
/> />
{tag.name} {tag.name}
</span> </span>
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span> <span className="text-xs text-slate-500">
{recentClicks > 0 ? `${recentClicks.toLocaleString()} recent clicks` : `${usageCount.toLocaleString()} uses`}
</span>
</button> </button>
) )
})} })}

View File

@@ -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 = '<meta name="csrf-token" content="test-token">'
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(<BulkTagModal open mode="add" onClose={() => {}} 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(<BulkTagModal open mode="add" onClose={() => {}} 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])
})
})

View File

@@ -52,7 +52,8 @@ function toSuggestionItems(raw) {
key: item?.id ?? tag, key: item?.id ?? tag,
label: item?.name || item?.tag || item?.slug || tag, label: item?.name || item?.tag || item?.slug || tag,
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'), isAi: Boolean(item?.is_ai || item?.source === 'ai'),
} }
}) })
@@ -173,6 +174,9 @@ function SuggestionDropdown({
{!loading && !error && suggestions.map((item, index) => { {!loading && !error && suggestions.map((item, index) => {
const active = highlightedIndex === index const active = highlightedIndex === index
const detailLabel = item.recentClicks > 0
? `${item.recentClicks.toLocaleString()} recent`
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
return ( return (
<li <li
key={item.key} key={item.key}
@@ -192,8 +196,8 @@ function SuggestionDropdown({
</span> </span>
)} )}
</div> </div>
{typeof item.usageCount === 'number' && ( {detailLabel && (
<span className="shrink-0 text-[11px] text-white/50">{item.usageCount}</span> <span className="shrink-0 text-[11px] text-white/50">{detailLabel}</span>
)} )}
</li> </li>
) )

View File

@@ -26,8 +26,8 @@ describe('TagInput', () => {
return { return {
data: { data: {
data: [ data: [
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 }, { id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10, recent_clicks: 2 },
{ id: 2, name: 'city', slug: 'city', usage_count: 30 }, { 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() expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
}) })
it('shows recent click momentum when suggestions provide it', async () => {
render(<Harness />)
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 () => { it('supports comma-separated paste', async () => {
render(<Harness />) render(<Harness />)

View File

@@ -37,15 +37,18 @@ function toListItem(item) {
if (!item) return null if (!item) return null
if (typeof item === 'string') { if (typeof item === 'string') {
const slug = normalizeSlug(item) 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 || '') const slug = normalizeSlug(item.slug || item.tag || item.name || '')
if (!slug) return null if (!slug) return null
const usageCount = Number(item.usage_count)
const recentClicks = Number(item.recent_clicks)
return { return {
key: String(item.id ?? slug), key: String(item.id ?? slug),
slug, slug,
name: item.name || item.tag || item.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'), isAi: Boolean(item.is_ai || item.source === 'ai'),
} }
} }
@@ -123,6 +126,10 @@ function AddNewRow({ label, onAdd, disabled }) {
} }
function ListRow({ item, isSelected, onToggle, 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 ( return (
<button <button
type="button" type="button"
@@ -154,9 +161,9 @@ function ListRow({ item, isSelected, onToggle, disabled }) {
)} )}
</span> </span>
{typeof item.usageCount === 'number' && ( {detailLabel && (
<span className="ml-3 shrink-0 text-[11px] text-white/40"> <span className="ml-3 shrink-0 text-[11px] text-white/40">
{item.usageCount.toLocaleString()} uses {detailLabel}
</span> </span>
)} )}
</button> </button>

View File

@@ -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 (
<TagPicker
value={tags}
onChange={setTags}
suggestedTags={['sunset']}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
/>
)
}
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(<Harness />)
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(<Harness />)
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()
})
})

View File

@@ -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(() => {
})
}

View File

@@ -14,6 +14,7 @@ if (!window.Alpine) {
// Gallery navigation context: stores artwork list for prev/next on artwork page // Gallery navigation context: stores artwork list for prev/next on artwork page
import './lib/nav-context.js'; import './lib/nav-context.js';
import { sendTagInteractionEvent } from './lib/tagAnalytics';
function mountStoryEditor() { function mountStoryEditor() {
var storyEditorRoot = document.getElementById('story-editor-react-root'); var storyEditorRoot = document.getElementById('story-editor-react-root');
@@ -61,6 +62,475 @@ function mountStoryEditor() {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 = '<div class="space-y-2 px-2 py-2">'
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-24 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-16 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
+ '<span class="h-3 w-10 animate-pulse rounded bg-white/[0.06]"></span>'
+ '</div>'
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-28 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-20 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
+ '<span class="h-3 w-12 animate-pulse rounded bg-white/[0.06]"></span>'
+ '</div>'
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-20 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-14 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
+ '<span class="h-3 w-8 animate-pulse rounded bg-white/[0.06]"></span>'
+ '</div>'
+ '</div>';
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 '<div class="px-2 pb-2">'
+ '<div class="flex items-center justify-between gap-3 px-2 pb-2 pt-1">'
+ '<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/30">Recent searches</div>'
+ '<button type="button" data-tags-search-clear-recent class="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/48 transition hover:bg-white/[0.08] hover:text-white">Clear</button>'
+ '</div>'
+ '<div class="flex flex-wrap gap-2 px-2 pb-2">'
+ items.map(function (item) {
var encoded = encodeURIComponent(item);
var escaped = escapeHtml(item);
return '<span class="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] pr-1 text-xs font-medium text-white/68 transition hover:bg-white/[0.08] hover:text-white">'
+ '<a href="/tags?q=' + encoded + '" data-tags-search-item data-tags-search-surface="recent_search" data-tags-search-query="' + escaped + '" role="option" aria-selected="false" class="rounded-full px-3 py-1.5">' + escaped + '</a>'
+ '<button type="button" data-tags-search-remove-recent data-tags-search-query="' + escaped + '" class="inline-flex h-6 w-6 items-center justify-center rounded-full text-white/38 transition hover:bg-black/20 hover:text-white" aria-label="Remove recent search ' + escaped + '">'
+ '&times;'
+ '</button>'
+ '</span>';
}).join('')
+ '</div>'
+ '</div>';
}
function renderRescueSuggestions(items, query) {
if (!items.length) return '';
return '<div class="border-t border-white/8 px-2 pt-3">'
+ '<div class="px-2 pb-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/30">Try these instead</div>'
+ '<div class="grid gap-1.5 px-1 pb-2">'
+ items.map(function (item, index) {
var name = escapeHtml(item.name || '');
var slug = escapeHtml(item.slug || '');
return '<a href="/tag/' + slug + '" data-tags-search-item data-tags-search-surface="rescue_suggestion" data-tags-search-tag="' + slug + '" data-tags-search-query="' + escapeHtml(query || '') + '" data-tags-search-position="' + (index + 1) + '" role="option" aria-selected="false" class="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white">'
+ '<span class="min-w-0"><span class="block truncate font-medium text-white">' + name + '</span><span class="block truncate text-xs text-white/34">#' + slug + '</span></span>'
+ '<span class="shrink-0 text-xs text-white/30">Popular</span>'
+ '</a>';
}).join('')
+ '</div>'
+ '<div class="px-2 pb-2"><a href="/tags" class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-200 transition hover:text-white">Browse full tags index</a></div>'
+ '</div>';
}
function renderNoMatchState(query, rescueItems) {
title.textContent = 'No matching tags';
results.setAttribute('aria-busy', 'false');
results.innerHTML = '<div class="rounded-xl px-3 py-4 text-sm text-white/42">No direct matches for <span class="text-white">' + escapeHtml(query) + '</span>. Try a broader keyword or jump into one of these popular tags.</div>'
+ 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 =
'<span class="flex min-w-0 items-center gap-3">'
+ '<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-white/[0.06] text-xs text-sky-200">#</span>'
+ '<span class="min-w-0"><span class="block truncate font-medium text-white">' + itemName + '</span><span class="block truncate text-xs text-white/36">#' + itemSlug + '</span></span>'
+ '</span>'
+ '<span class="shrink-0 text-xs text-white/34">' + metricLabel + '</span>';
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 () {
function initBlurPreviewImages() { function initBlurPreviewImages() {
var selector = 'img[data-blur-preview]'; var selector = 'img[data-blur-preview]';

View File

@@ -2,9 +2,23 @@
@section('content') @section('content')
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10"> <div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
<div class="space-y-6">
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6"> <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Queue</h1> <h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Hub</h1>
<p class="mt-2 text-sm text-gray-500">Internal reporting entry points for moderation and discovery analytics.</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<a href="{{ route('admin.reports.tags') }}" class="rounded-xl border border-sky-200 bg-sky-50 p-6 transition hover:border-sky-300 hover:bg-sky-100/80 dark:border-sky-900/60 dark:bg-sky-950/30 dark:hover:border-sky-700 dark:hover:bg-sky-950/50">
<h2 class="text-lg font-semibold text-slate-900 dark:text-sky-100">Tag Interaction Report</h2>
<p class="mt-2 text-sm text-slate-600 dark:text-sky-200/70">Inspect top surfaces, tags, search terms, and related-tag transitions from the new tag analytics pipeline.</p>
</a>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Moderation Queue</h2>
<p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p> <p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p>
</div> </div>
</div>
</div>
</div> </div>
@endsection @endsection

View File

@@ -0,0 +1,216 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="mx-auto max-w-7xl space-y-8">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 class="text-2xl font-bold text-white">Tag Interaction Report</h1>
<p class="mt-2 max-w-3xl text-sm text-neutral-400">
Internal dashboard for tag discovery clicks. Use it to inspect surface performance, top tags, query demand, and tag-to-tag transitions for recommendation tuning.
</p>
</div>
<div class="flex flex-wrap gap-3 text-xs">
<a href="{{ route('api.admin.reports.tags', request()->query()) }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">JSON report</a>
<a href="{{ route('admin.reports.queue') }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">Reports hub</a>
</div>
</div>
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<form method="GET" action="{{ route('admin.reports.tags') }}" class="grid gap-4 md:grid-cols-4">
<label class="space-y-2 text-sm text-neutral-300">
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">From</span>
<input type="date" name="from" value="{{ $filters['from'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
</label>
<label class="space-y-2 text-sm text-neutral-300">
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">To</span>
<input type="date" name="to" value="{{ $filters['to'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
</label>
<label class="space-y-2 text-sm text-neutral-300">
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Row limit</span>
<input type="number" min="1" max="100" name="limit" value="{{ $filters['limit'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
</label>
<div class="flex items-end gap-3">
<button type="submit" class="inline-flex items-center rounded-lg bg-sky-500 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-400">Refresh</button>
</div>
</form>
<div class="mt-4 flex flex-wrap gap-4 text-xs text-neutral-500">
<span>Latest aggregated date: <span class="font-medium text-neutral-300">{{ $latestAggregatedDate ?? 'not aggregated yet' }}</span></span>
<span>Latest raw event: <span class="font-medium text-neutral-300">{{ $overview['latest_event_at'] ?? 'n/a' }}</span></span>
</div>
@if(app()->environment('local'))
<div class="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-100">
<p class="font-semibold">Local demo data</p>
<p class="mt-1 text-amber-100/80">
This report can be filled locally with seeded click data. Run
<code class="rounded bg-black/30 px-2 py-1 text-xs text-amber-50">php artisan analytics:seed-tag-interaction-demo --days=14 --per-day=80 --refresh</code>
and refresh this page to inspect realistic search, recommendation, and transition metrics.
</p>
</div>
@endif
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Total clicks</p>
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['total_clicks']) }}</p>
</div>
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique users</p>
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_users']) }}</p>
</div>
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique sessions</p>
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_sessions']) }}</p>
</div>
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Distinct tags</p>
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['distinct_tags']) }}</p>
</div>
</div>
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-white">Daily Click Trend</h2>
<p class="mt-1 text-sm text-neutral-500">Daily rollups for tuning trending and recommendation decisions.</p>
</div>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@forelse($dailyClicks as $row)
<div class="rounded-lg border border-neutral-800 bg-neutral-950/70 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-neutral-500">{{ $row['date'] }}</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($row['clicks']) }}</p>
<p class="mt-1 text-xs text-neutral-500">clicks</p>
</div>
@empty
<p class="text-sm text-neutral-500">No aggregated rows available for the selected range yet.</p>
@endforelse
</div>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<h2 class="text-lg font-semibold text-white">Top Surfaces</h2>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
<th class="pb-3 pr-4">Surface</th>
<th class="pb-3 pr-4">Clicks</th>
<th class="pb-3 pr-4">Users</th>
<th class="pb-3 pr-4">Sessions</th>
<th class="pb-3">Avg pos.</th>
</tr>
</thead>
<tbody>
@forelse($bySurface as $row)
<tr class="border-b border-neutral-800/70 text-neutral-200">
<td class="py-3 pr-4 font-medium text-white">{{ $row['surface'] }}</td>
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
<td class="py-3 pr-4">{{ number_format($row['unique_users']) }}</td>
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
</tr>
@empty
<tr><td colspan="5" class="py-4 text-neutral-500">No surface data in this range.</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<h2 class="text-lg font-semibold text-white">Top Query Terms</h2>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
<th class="pb-3 pr-4">Query</th>
<th class="pb-3 pr-4">Clicks</th>
<th class="pb-3 pr-4">Sessions</th>
<th class="pb-3">Resolved tags</th>
</tr>
</thead>
<tbody>
@forelse($topQueries as $row)
<tr class="border-b border-neutral-800/70 text-neutral-200">
<td class="py-3 pr-4 font-medium text-white">{{ $row['query'] }}</td>
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
<td class="py-3">{{ number_format($row['resolved_tags']) }}</td>
</tr>
@empty
<tr><td colspan="4" class="py-4 text-neutral-500">No query data in this range.</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<h2 class="text-lg font-semibold text-white">Top Tags</h2>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
<th class="pb-3 pr-4">Tag</th>
<th class="pb-3 pr-4">Clicks</th>
<th class="pb-3 pr-4">Recommendation</th>
<th class="pb-3 pr-4">Search</th>
<th class="pb-3">Sessions</th>
</tr>
</thead>
<tbody>
@forelse($topTags as $row)
<tr class="border-b border-neutral-800/70 text-neutral-200">
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
<td class="py-3 pr-4">{{ number_format($row['recommendation_clicks']) }}</td>
<td class="py-3 pr-4">{{ number_format($row['search_clicks']) }}</td>
<td class="py-3">{{ number_format($row['unique_sessions']) }}</td>
</tr>
@empty
<tr><td colspan="5" class="py-4 text-neutral-500">No tag click data in this range.</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
<h2 class="text-lg font-semibold text-white">Top Tag Transitions</h2>
<p class="mt-1 text-sm text-neutral-500">Most-clicked source tag to target tag paths from related-tag surfaces.</p>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
<th class="pb-3 pr-4">Source</th>
<th class="pb-3 pr-4">Target</th>
<th class="pb-3 pr-4">Clicks</th>
<th class="pb-3 pr-4">Sessions</th>
<th class="pb-3">Avg pos.</th>
</tr>
</thead>
<tbody>
@forelse($topTransitions as $row)
<tr class="border-b border-neutral-800/70 text-neutral-200">
<td class="py-3 pr-4 font-medium text-white">#{{ $row['source_tag_slug'] }}</td>
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
</tr>
@empty
<tr><td colspan="5" class="py-4 text-neutral-500">No transition data in this range.</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
</div>
</div>
@endsection

View File

@@ -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
<header {{ $attributes->class([$resolvedHeaderClass]) }}>
<div class="{{ $resolvedInnerClass }}">
<div class="{{ $resolvedContentClass }}">
@if($showSection && filled($section))
<p class="{{ $resolvedSectionClass }}">{{ $section }}</p>
@endif
@if($showTitle && filled($title))
<h1 class="{{ $resolvedTitleClass }}">
@if($showIcon && filled($icon))
<i class="fa-solid {{ $icon }} {{ $resolvedIconClass }}"></i>
@endif
<span class="min-w-0">{{ $title }}</span>
</h1>
@endif
@if($showBreadcrumbs && $headerBreadcrumbs->isNotEmpty())
<div class="mt-3">
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs])
</div>
@endif
@if($showDescription && filled($description))
<p class="{{ $resolvedDescriptionClass }}">{!! $description !!}</p>
@endif
</div>
@if($hasActions)
<div class="{{ $resolvedActionsClass }}">
{{ $actions }}
</div>
@endif
</div>
</header>

View File

@@ -6,6 +6,7 @@
@php @php
$active = $section ?? 'artworks'; $active = $section ?? 'artworks';
$includeTags = (bool) ($includeTags ?? false);
$sections = collect([ $sections = collect([
'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'], 'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'],
@@ -14,6 +15,10 @@
'skins' => ['label' => 'Skins', 'icon' => 'fa-layer-group', 'href' => '/skins'], 'skins' => ['label' => 'Skins', 'icon' => 'fa-layer-group', 'href' => '/skins'],
'other' => ['label' => 'Other', 'icon' => 'fa-folder-open', 'href' => '/other'], 'other' => ['label' => 'Other', 'icon' => 'fa-folder-open', 'href' => '/other'],
]); ]);
if ($includeTags) {
$sections->put('tags', ['label' => 'Tags', 'icon' => 'fa-tags', 'href' => '/tags']);
}
@endphp @endphp
<nav class="flex flex-wrap items-center gap-2 text-sm" aria-label="Browse sections"> <nav class="flex flex-wrap items-center gap-2 text-sm" aria-label="Browse sections">

View File

@@ -69,6 +69,8 @@
$rankApiEndpoint = '/api/rank/global'; $rankApiEndpoint = '/api/rank/global';
} }
} }
$tagContext = ($gallery_type ?? null) === 'tag' ? ($tag_context ?? null) : null;
@endphp @endphp
@section('content') @section('content')
@@ -76,13 +78,15 @@
@php Banner::ShowResponsiveAd(); @endphp @php Banner::ShowResponsiveAd(); @endphp
@php @php
$browseSection = isset($contentType) && $contentType ? strtolower((string) $contentType->slug) : 'artworks'; $browseSection = $gallery_nav_section
?? (isset($contentType) && $contentType ? strtolower((string) $contentType->slug) : (($gallery_type ?? null) === 'tag' ? 'tags' : 'artworks'));
$browseIconMap = [ $browseIconMap = [
'artworks' => 'fa-border-all', 'artworks' => 'fa-border-all',
'photography' => 'fa-camera', 'photography' => 'fa-camera',
'wallpapers' => 'fa-desktop', 'wallpapers' => 'fa-desktop',
'skins' => 'fa-layer-group', 'skins' => 'fa-layer-group',
'other' => 'fa-folder-open', 'other' => 'fa-folder-open',
'tags' => 'fa-tags',
]; ];
$browseIcon = $browseIconMap[$browseSection] ?? 'fa-border-all'; $browseIcon = $browseIconMap[$browseSection] ?? 'fa-border-all';
@endphp @endphp
@@ -94,39 +98,141 @@
<main class="w-full"> <main class="w-full">
{{-- ── Hero header (discover-style) ── --}} {{-- ── Hero header (discover-style) ── --}}
<header class="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">
@php @php
$headerBreadcrumbs = collect(array_filter([ $headerBreadcrumbs = collect();
isset($contentType) && $contentType ? (object) ['name' => 'Explore', 'url' => '/explore'] : null,
isset($contentType) && $contentType ? (object) ['name' => $contentType->name, 'url' => '/explore/' . strtolower($contentType->slug)] : (object) ['name' => 'Explore', 'url' => '/explore'], if (($gallery_type ?? null) === 'browse') {
...(($gallery_type ?? null) === 'category' && isset($breadcrumbs) ? $breadcrumbs->all() : []), $headerBreadcrumbs = collect([
])); (object) ['name' => 'Browse', 'url' => '/browse'],
]);
} elseif (isset($contentType) && $contentType) {
$headerBreadcrumbs = collect([
(object) ['name' => 'Browse', 'url' => '/browse'],
(object) ['name' => $contentType->name, 'url' => '/' . strtolower($contentType->slug)],
]);
if (($gallery_type ?? null) === 'category' && isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
$headerBreadcrumbs = $breadcrumbs;
}
} elseif (isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
$headerBreadcrumbs = $breadcrumbs;
}
@endphp @endphp
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <x-nova-page-header
<div class="max-w-3xl"> section="{{ ($gallery_type ?? null) === 'tag' ? 'Tags' : 'Browse' }}"
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Browse</p> :title="$hero_title ?? 'Browse Artworks'"
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3"> :icon="$browseIcon"
<i class="fa-solid {{ $browseIcon }} text-sky-400 text-2xl"></i> :breadcrumbs="$headerBreadcrumbs"
{{ $hero_title ?? 'Browse Artworks' }} :description="$hero_description ?? null"
</h1> actionsClass="lg:pt-8"
@if(!empty($hero_description)) >
<p class="mt-1 text-sm text-white/50">{!! $hero_description !!}</p> <x-slot name="actions">
@include('gallery._browse_nav', ['section' => $browseSection, 'includeTags' => ($gallery_type ?? null) === 'tag'])
</x-slot>
</x-nova-page-header>
@if($tagContext)
<section class="px-6 pt-8 md:px-10">
@php
$topCompanionTag = collect($tagContext['related_tags'] ?? [])->first();
@endphp
<div class="overflow-hidden rounded-[1.75rem] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_34%),linear-gradient(135deg,rgba(11,17,27,0.96),rgba(10,16,24,0.88))] shadow-[0_20px_70px_rgba(3,7,18,0.24)]">
<div class="grid gap-6 p-6 md:p-7 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,0.85fr)] xl:items-start">
<div class="space-y-6">
<div class="flex flex-wrap items-center gap-3 text-sm text-white/56">
<span class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200">
<span class="h-2 w-2 rounded-full bg-sky-300"></span>
Tag feed
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-medium text-white/60">
Sorted by {{ $tagContext['current_sort_label'] ?? 'Most viewed' }}
</span>
</div>
<div class="space-y-3">
<h2 class="text-2xl font-semibold tracking-tight text-white md:text-3xl">
#{{ $tagContext['slug'] ?? $tagContext['name'] }}
</h2>
<p class="max-w-3xl text-sm leading-6 text-white/62 md:text-base">
A focused feed for artwork tied to this theme. Use the ranking tabs to switch between momentum, recency, and quality without leaving the tag context.
</p>
@if($topCompanionTag && isset($topCompanionTag->shared_artworks_count))
<a href="{{ route('tags.show', $topCompanionTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="top_companion" data-tag-analytics-tag="{{ $topCompanionTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="1" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3.5 py-2 text-sm text-white/68 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white">
<span class="text-sky-200">Top companion</span>
<span class="font-medium text-white">#{{ $topCompanionTag->name }}</span>
<span class="text-white/34">{{ number_format($topCompanionTag->shared_artworks_count) }} shared artworks</span>
@if(isset($topCompanionTag->transition_clicks) && (int) $topCompanionTag->transition_clicks > 0)
<span class="text-emerald-200">{{ number_format((int) $topCompanionTag->transition_clicks) }} recent clicks</span>
@endif
</a>
@endif @endif
</div> </div>
<div class="flex flex-col items-start gap-3 lg:items-end"> @if(collect($tagContext['related_tags'] ?? [])->isNotEmpty())
<div class="hidden lg:flex lg:justify-end"> <div>
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs]) <p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/38">Related tags</p>
</div> <div class="mt-3 flex flex-wrap gap-2.5">
@include('gallery._browse_nav', ['section' => $browseSection]) @foreach($tagContext['related_tags'] as $relatedIndex => $relatedTag)
</div> <a href="{{ route('tags.show', $relatedTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="related_chip" data-tag-analytics-tag="{{ $relatedTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="{{ $relatedIndex + 1 }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3.5 py-2 text-sm text-white/72 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white">
<span>#{{ $relatedTag->name }}</span>
<span class="text-xs text-white/36">
{{ number_format($relatedTag->shared_artworks_count ?? $relatedTag->usage_count) }}
{{ isset($relatedTag->shared_artworks_count) ? 'shared' : 'uses' }}
</span>
@if(isset($relatedTag->transition_clicks) && (int) $relatedTag->transition_clicks > 0)
<span class="text-xs text-emerald-200">{{ number_format((int) $relatedTag->transition_clicks) }} recent</span>
@endif
</a>
@endforeach
</div> </div>
<div class="mt-4 lg:hidden"> <div class="mt-4 grid gap-3 md:grid-cols-3">
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs]) @foreach(collect($tagContext['related_tags'])->take(3)->values() as $clusterIndex => $clusterTag)
<a href="{{ route('tags.show', $clusterTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="related_cluster" data-tag-analytics-tag="{{ $clusterTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="{{ $clusterIndex + 1 }}" class="rounded-2xl border border-white/10 bg-white/[0.04] p-4 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08]">
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/34">Related cluster</p>
<h3 class="mt-2 text-base font-semibold text-white">#{{ $clusterTag->name }}</h3>
<p class="mt-2 text-sm text-white/52">
{{ number_format($clusterTag->shared_artworks_count ?? 0) }} shared artworks with this tag feed.
</p>
@if(isset($clusterTag->transition_clicks) && (int) $clusterTag->transition_clicks > 0)
<p class="mt-2 text-xs font-medium uppercase tracking-[0.18em] text-emerald-200">{{ number_format((int) $clusterTag->transition_clicks) }} recent clicks</p>
@endif
</a>
@endforeach
</div> </div>
</header> </div>
@endif
</div>
<aside class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Artworks</p>
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagContext['artworks_total'] ?? 0)) }}</p>
<p class="mt-2 text-sm text-white/45">Public approved artworks currently in this feed.</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Total uses</p>
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagContext['usage_count'] ?? 0)) }}</p>
<p class="mt-2 text-sm text-white/45">How often this tag is attached across the catalog.</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Feed tools</p>
<div class="mt-3 flex flex-wrap gap-2">
<a href="{{ route('tags.index') }}" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm font-medium text-white/72 transition hover:bg-white/[0.08] hover:text-white">
All tags
</a>
<a href="{{ $tagContext['rss_url'] ?? '#' }}" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm font-medium text-white/72 transition hover:bg-white/[0.08] hover:text-white">
RSS
</a>
</div>
<p class="mt-3 text-sm text-white/45">Jump back to discovery or subscribe to this tag feed.</p>
</div>
</aside>
</div>
</div>
</section>
@endif
{{-- ═══════════════════════════════════════════════════════════════ --}} {{-- ═══════════════════════════════════════════════════════════════ --}}
{{-- RANKING TABS --}} {{-- RANKING TABS --}}
@@ -188,9 +294,11 @@
@php @php
$filterItems = $subcategories ?? collect(); $filterItems = $subcategories ?? collect();
$activeFilterId = isset($category) ? ($category->id ?? null) : null; $activeFilterId = isset($category) ? ($category->id ?? null) : null;
$categoryAllHref = isset($contentType) && $contentType $categoryAllHref = isset($subcategory_parent) && $subcategory_parent && ($subcategory_parent->url ?? null)
? url($subcategory_parent->url)
: (isset($contentType) && $contentType
? url('/' . $contentType->slug) ? url('/' . $contentType->slug)
: url('/browse'); : url('/browse'));
$activeSortSlug = $activeTab !== 'trending' ? $activeTab : null; $activeSortSlug = $activeTab !== 'trending' ? $activeTab : null;
@endphp @endphp
@@ -382,7 +490,6 @@
@push('scripts') @push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx') @vite('resources/js/entry-masonry-gallery.jsx')
@vite('resources/js/entry-pill-carousel.jsx') @vite('resources/js/entry-pill-carousel.jsx')
<script src="/js/legacy-gallery-init.js" defer></script>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';

View File

@@ -1,24 +1,279 @@
@extends('layouts.nova.content-layout') @extends('layouts.nova')
@php @php
$hero_title = 'Tags'; $hero_title = 'Tags';
$hero_description = 'Browse all artwork tags on Skinbase.'; $hero_description = 'Browse all artwork tags on Skinbase.';
$breadcrumbs = $breadcrumbs ?? collect([ $breadcrumbs = $breadcrumbs ?? collect([
(object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => 'Browse', 'url' => '/browse'],
(object) ['name' => 'Tags', 'url' => '/tags'], (object) ['name' => 'Tags', 'url' => '/tags'],
]); ]);
@endphp @endphp
@section('page-content') @push('head')
@isset($page_canonical)
<link rel="canonical" href="{{ $page_canonical }}" />
@endisset
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}" />
<meta property="og:title" content="{{ $page_title ?? 'Skinbase' }}" />
<meta property="og:description" content="{{ $page_meta_description ?? $hero_description }}" />
<meta property="og:site_name" content="Skinbase" />
@endpush
@section('content')
@php
$query = trim((string) ($query ?? ''));
$featuredTags = $featuredTags ?? collect();
$risingTags = $risingTags ?? collect();
$tagStats = $tagStats ?? ['active' => 0, 'usage' => 0, 'matching' => $tags->total(), 'recent_clicks' => 0];
$topFeaturedTag = $featuredTags->first();
@endphp
<div class="container-fluid legacy-page">
<div class="pt-0">
<div class="mx-auto w-full">
<div class="relative min-h-[calc(100vh-64px)]">
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 overflow-hidden">
<div class="absolute left-[-6rem] top-10 h-56 w-56 rounded-full bg-sky-500/10 blur-3xl"></div>
<div class="absolute right-[-4rem] top-24 h-64 w-64 rounded-full bg-cyan-400/10 blur-3xl"></div>
<div class="absolute left-1/3 top-56 h-48 w-48 rounded-full bg-emerald-400/10 blur-3xl"></div>
</div>
<main class="w-full">
<x-nova-page-header
section="Browse"
title="Tags"
icon="fa-tags"
:breadcrumbs="$breadcrumbs"
:description="$hero_description"
actionsClass="lg:pt-8"
>
<x-slot name="actions">
@include('gallery._browse_nav', ['section' => 'tags', 'includeTags' => true])
</x-slot>
</x-nova-page-header>
<section class="px-6 pb-16 pt-8 md:px-10">
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.9fr)]">
<div class="overflow-hidden rounded-[1.75rem] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_38%),linear-gradient(135deg,rgba(13,19,30,0.98),rgba(8,14,24,0.92))] shadow-[0_24px_80px_rgba(3,7,18,0.32)]">
<div class="grid gap-8 p-6 md:p-8 xl:grid-cols-[minmax(0,1.2fr)_minmax(260px,0.8fr)] xl:items-end">
<div class="space-y-6">
<div class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200">
<span class="h-2 w-2 rounded-full bg-sky-300"></span>
Explore by vibe, medium, and theme
</div>
<div class="space-y-3">
<h2 class="max-w-2xl text-3xl font-semibold tracking-tight text-white md:text-4xl xl:text-[2.8rem] xl:leading-[1.05]">
Find collections faster with a cleaner tag browsing experience.
</h2>
<p class="max-w-2xl text-sm leading-6 text-white/64 md:text-base">
Jump into the most used themes on Skinbase, search by keyword, and move from discovery to relevant artwork feeds without scanning an endless wall of chips.
</p>
</div>
<form method="GET" action="{{ route('tags.index') }}" class="space-y-3" data-tags-search-form>
<label for="tags-search" class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Search tags</label>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="relative flex-1" data-tags-search-root data-search-endpoint="/api/tags/search" data-popular-endpoint="/api/tags/popular">
<i class="fa-solid fa-magnifying-glass pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm text-white/35"></i>
<input
id="tags-search"
type="search"
name="q"
value="{{ $query }}"
placeholder="Search aesthetics, games, styles..."
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="tags-search-suggestions"
data-tags-search-input
class="h-12 w-full rounded-2xl border border-white/10 bg-black/25 pl-11 pr-4 text-sm text-white placeholder:text-white/30 focus:border-sky-400/45 focus:outline-none focus:ring-2 focus:ring-sky-400/20"
>
<div id="tags-search-suggestions" data-tags-search-panel class="absolute left-0 right-0 top-[calc(100%+0.75rem)] z-20 hidden overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,30,0.98),rgba(8,14,24,0.98))] shadow-[0_18px_50px_rgba(3,7,18,0.36)]">
<div class="border-b border-white/8 px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.24em] text-white/38" data-tags-search-title>
Suggested tags
</div>
<div class="max-h-72 overflow-y-auto p-2" data-tags-search-results role="listbox"></div>
</div>
</div>
<div class="flex gap-3 sm:shrink-0">
<button type="submit" class="inline-flex h-12 items-center justify-center rounded-2xl bg-sky-500 px-5 text-sm font-semibold text-slate-950 transition hover:bg-sky-400">
Search
</button>
@if($query !== '')
<a href="{{ route('tags.index') }}" class="inline-flex h-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] px-5 text-sm font-semibold text-white/72 transition hover:bg-white/[0.08] hover:text-white">
Reset
</a>
@endif
</div>
</div>
</form>
@if($risingTags->isNotEmpty())
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Popular right now</p>
<p class="text-xs text-white/35">{{ $risingTags->count() }} quick jumps tuned by recent clicks</p>
</div>
<div class="flex flex-wrap gap-2.5">
@foreach($risingTags as $tag)
<a href="{{ route('tags.show', $tag->slug) }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3.5 py-2 text-sm text-white/72 transition hover:border-sky-400/30 hover:bg-sky-400/10 hover:text-white">
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.08] text-[11px] text-sky-200">#</span>
<span>{{ $tag->name }}</span>
</a>
@endforeach
</div>
</div>
@endif
</div>
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Active tags</p>
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['active']) }}</p>
<p class="mt-2 text-sm text-white/45">Browsable tags with active artwork associations.</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Total usage</p>
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['usage']) }}</p>
<p class="mt-2 text-sm text-white/45">Tag assignments used across the catalog.</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Matching now</p>
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['matching']) }}</p>
<p class="mt-2 text-sm text-white/45">
{{ $query !== '' ? 'Results for your current search term.' : 'The current catalog available to browse.' }}
</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Recent clicks</p>
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagStats['recent_clicks'] ?? 0)) }}</p>
<p class="mt-2 text-sm text-white/45">Last 14 days of tag discovery clicks used to tune highlights.</p>
</div>
</div>
</div>
</div>
<aside class="rounded-[1.75rem] border border-white/[0.08] bg-white/[0.04] p-6 shadow-[0_16px_60px_rgba(3,7,18,0.22)] backdrop-blur-sm">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Featured tag</p>
<h3 class="mt-2 text-2xl font-semibold text-white">{{ $topFeaturedTag?->name ?? 'No featured tag yet' }}</h3>
</div>
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-400/12 text-sky-200">
<i class="fa-solid fa-wand-magic-sparkles text-lg"></i>
</div>
</div>
@if($topFeaturedTag)
<p class="mt-4 text-sm leading-6 text-white/58">
One of the strongest tags in current discovery behavior, with {{ number_format($topFeaturedTag->artworks_count) }} artworks currently attached.
</p>
<a href="{{ route('tags.show', $topFeaturedTag->slug) }}" class="mt-6 inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">
Open #{{ $topFeaturedTag->slug }}
<i class="fa-solid fa-arrow-right text-xs"></i>
</a>
@else
<p class="mt-4 text-sm leading-6 text-white/58">Tag highlights will appear here as soon as the catalog has enough data.</p>
@endif
<div class="mt-8 border-t border-white/10 pt-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Browse tips</p>
<div class="mt-4 space-y-3 text-sm text-white/56">
<div class="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
Use broad tags like <span class="text-white">anime</span> or <span class="text-white">minimal</span> to start wide, then narrow down inside each feed.
</div>
<div class="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
Search matches both display names and slugs, so shorthand and full names work.
</div>
</div>
</div>
</aside>
</div>
@if($featuredTags->isNotEmpty())
<div class="mt-8 rounded-[1.75rem] border border-white/[0.08] bg-white/[0.03] p-6 md:p-7">
<div class="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Editor's picks</p>
<h2 class="mt-2 text-2xl font-semibold text-white">High-signal tags worth exploring first</h2>
</div>
<p class="text-sm text-white/48">Ranked by recent discovery clicks first, then usage and artwork volume.</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach($featuredTags as $index => $tag)
<a href="{{ route('tags.show', $tag->slug) }}" class="group rounded-[1.35rem] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] p-5 transition duration-200 hover:-translate-y-0.5 hover:border-sky-400/30 hover:bg-sky-400/[0.08]">
<div class="flex items-start justify-between gap-3">
<span class="inline-flex h-9 min-w-9 items-center justify-center rounded-xl bg-sky-400/12 px-3 text-sm font-semibold text-sky-200">
{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}
</span>
<span class="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/42">
{{ number_format((int) ($tag->recent_clicks ?? 0)) }} recent clicks
</span>
</div>
<div class="mt-6 flex items-center gap-2 text-white">
<i class="fa-solid fa-hashtag text-sky-300"></i>
<h3 class="text-xl font-semibold tracking-tight">{{ $tag->name }}</h3>
</div>
<p class="mt-3 text-sm leading-6 text-white/56">
{{ number_format($tag->artworks_count) }} artworks tagged. Open the feed to see the strongest matches first.
</p>
<div class="mt-6 inline-flex items-center gap-2 text-sm font-medium text-white/72 transition group-hover:text-white">
Browse tag
<i class="fa-solid fa-arrow-right text-xs transition group-hover:translate-x-0.5"></i>
</div>
</a>
@endforeach
</div>
</div>
@endif
<div class="mt-8 rounded-[1.75rem] border border-white/[0.08] bg-white/[0.02] p-6 md:p-7">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">All tags</p>
<h2 class="mt-2 text-2xl font-semibold text-white">
{{ $query !== '' ? 'Search results' : 'Browse the full catalog' }}
</h2>
</div>
<p class="text-sm text-white/46">
Showing {{ number_format($tags->count()) }} of {{ number_format($tags->total()) }} tags.
@if($query !== '')
<span>Matching &quot;{{ $query }}&quot;.</span>
@endif
</p>
</div>
@if($tags->isNotEmpty()) @if($tags->isNotEmpty())
<div class="flex flex-wrap gap-2"> <div class="mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
@foreach($tags as $tag) @foreach($tags as $tag)
<a href="{{ route('tags.show', $tag->slug) }}" <a href="{{ route('tags.show', $tag->slug) }}" class="group flex min-h-[132px] flex-col justify-between rounded-[1.25rem] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] p-4 transition duration-200 hover:border-sky-400/28 hover:bg-sky-400/[0.07] hover:shadow-[0_16px_40px_rgba(14,165,233,0.08)]">
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/[0.05] border border-white/[0.07] <div class="flex items-start justify-between gap-3">
text-sm text-white/70 hover:bg-white/[0.1] hover:text-white transition-colors"> <div class="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-white/[0.05] text-sky-300 transition group-hover:bg-sky-400/12">
<i class="fa-solid fa-hashtag text-xs text-sky-400/70"></i> <i class="fa-solid fa-hashtag text-sm"></i>
{{ $tag->name }} </div>
<span class="text-xs text-white/30 ml-1">{{ number_format($tag->artworks_count) }}</span> <span class="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/38">
{{ number_format($tag->artworks_count) }} artworks
</span>
</div>
<div class="mt-5">
<h3 class="text-lg font-semibold tracking-tight text-white">{{ $tag->name }}</h3>
<p class="mt-1 text-sm text-white/44">#{{ $tag->slug }}</p>
</div>
<div class="mt-5 flex items-center justify-between text-sm text-white/58 transition group-hover:text-white/78">
<span>{{ number_format($tag->usage_count) }} total uses</span>
<i class="fa-solid fa-arrow-up-right-from-square text-xs"></i>
</div>
</a> </a>
@endforeach @endforeach
</div> </div>
@@ -27,8 +282,27 @@
{{ $tags->withQueryString()->links() }} {{ $tags->withQueryString()->links() }}
</div> </div>
@else @else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center"> <div class="mt-6 rounded-[1.4rem] border border-dashed border-white/12 bg-black/20 px-8 py-12 text-center">
<p class="text-white/40 text-sm">No tags found.</p> <div class="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.05] text-sky-300">
<i class="fa-solid fa-tags text-xl"></i>
</div>
<h3 class="mt-5 text-xl font-semibold text-white">No tags matched this search</h3>
<p class="mx-auto mt-2 max-w-xl text-sm leading-6 text-white/50">
Try a broader keyword, remove punctuation, or reset the search to return to the full tag catalog.
</p>
<div class="mt-6 flex justify-center">
<a href="{{ route('tags.index') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/72 transition hover:bg-white/[0.08] hover:text-white">
View all tags
<i class="fa-solid fa-arrow-right text-xs"></i>
</a>
</div>
</div> </div>
@endif @endif
</div>
</section>
</main>
</div>
</div>
</div>
</div>
@endsection @endsection

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function (): void {
Schema::dropIfExists('tag_interaction_daily_metrics');
Schema::dropIfExists('tags');
Schema::dropIfExists('users');
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('username')->nullable();
$table->timestamp('username_changed_at')->nullable();
$table->timestamp('last_username_change_at')->nullable();
$table->string('onboarding_step')->nullable();
$table->string('name')->nullable();
$table->string('email')->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->boolean('is_active')->default(true);
$table->string('role')->nullable();
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
});
Schema::create('tags', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->unsignedInteger('usage_count')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
$table->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();
});
});
it('redirects guests away from the studio tag search route', function (): void {
$this->get('/api/studio/tags/search?q=hi')
->assertRedirect('/login');
});
it('returns momentum-ranked tag suggestions for authenticated studio users', function (): void {
$now = now();
$user = new User([
'username' => 'studio-user',
'username_changed_at' => now()->subDays(120),
'last_username_change_at' => now()->subDays(120),
'onboarding_step' => 'complete',
'name' => 'Studio User',
'email' => 'studio@example.com',
'email_verified_at' => now(),
'password' => 'password',
'is_active' => true,
]);
$user->id = 1;
DB::table('tags')->insert([
['id' => 1, 'name' => 'High Usage', 'slug' => 'high-usage', 'usage_count' => 500, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 2, 'name' => 'High Contrast', 'slug' => 'high-contrast', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 3, 'name' => 'Hidden Draft', 'slug' => 'hidden-draft', 'usage_count' => 900, 'is_active' => false, 'created_at' => $now, 'updated_at' => $now],
]);
DB::table('tag_interaction_daily_metrics')->insert([
[
'metric_date' => $now->toDateString(),
'surface' => 'tag_search',
'tag_slug' => 'high-contrast',
'source_tag_slug' => '',
'query' => 'hi',
'clicks' => 30,
'unique_users' => 0,
'unique_sessions' => 11,
'avg_position' => 1.1,
'created_at' => $now,
'updated_at' => $now,
],
[
'metric_date' => $now->toDateString(),
'surface' => 'tag_search',
'tag_slug' => 'high-usage',
'source_tag_slug' => '',
'query' => 'hi',
'clicks' => 3,
'unique_users' => 0,
'unique_sessions' => 2,
'avg_position' => 2.8,
'created_at' => $now,
'updated_at' => $now,
],
]);
$response = $this->actingAs($user)->getJson('/api/studio/tags/search?q=hi');
$response->assertOk();
$data = $response->json();
expect($data)->toHaveCount(2);
expect(array_column($data, 'slug'))->toBe(['high-contrast', 'high-usage']);
expect((int) $data[0]['recent_clicks'])->toBe(30);
expect($data[0])->toHaveKeys(['id', 'name', 'slug', 'usage_count', 'recent_clicks']);
expect($data[0])->not->toHaveKeys(['created_at', 'updated_at', 'is_active']);
});

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
use App\Services\Tags\TagDiscoveryService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function (): void {
Schema::dropIfExists('tag_interaction_daily_metrics');
Schema::dropIfExists('artwork_tag');
Schema::dropIfExists('artworks');
Schema::dropIfExists('tags');
Schema::create('tags', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->unsignedInteger('usage_count')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('artworks', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('title')->nullable();
$table->string('slug')->nullable();
$table->boolean('is_public')->default(true);
$table->boolean('is_approved')->default(true);
$table->timestamp('published_at')->nullable();
$table->softDeletes();
$table->timestamps();
});
Schema::create('artwork_tag', function (Blueprint $table): void {
$table->unsignedBigInteger('artwork_id');
$table->unsignedBigInteger('tag_id');
$table->string('source')->nullable();
$table->unsignedInteger('confidence')->nullable();
});
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
$table->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();
});
});
it('keeps slug in related tag payload when transition metrics are joined', function (): void {
$now = now();
DB::table('tags')->insert([
['id' => 1, 'name' => 'Primary', 'slug' => 'primary', 'usage_count' => 100, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 2, 'name' => 'Beta', 'slug' => 'beta', 'usage_count' => 80, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 3, 'name' => 'Gamma', 'slug' => 'gamma', 'usage_count' => 60, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
]);
DB::table('artworks')->insert([
['id' => 10, 'title' => 'One', 'slug' => 'one', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
['id' => 11, 'title' => 'Two', 'slug' => 'two', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
['id' => 12, 'title' => 'Three', 'slug' => 'three', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
]);
DB::table('artwork_tag')->insert([
['artwork_id' => 10, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
['artwork_id' => 10, 'tag_id' => 2, 'source' => 'user', 'confidence' => 100],
['artwork_id' => 11, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
['artwork_id' => 11, 'tag_id' => 2, 'source' => 'user', 'confidence' => 100],
['artwork_id' => 12, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
['artwork_id' => 12, 'tag_id' => 3, 'source' => 'user', 'confidence' => 100],
]);
DB::table('tag_interaction_daily_metrics')->insert([
[
'metric_date' => $now->toDateString(),
'surface' => 'related_chip',
'tag_slug' => 'beta',
'source_tag_slug' => 'primary',
'query' => '',
'clicks' => 25,
'unique_users' => 0,
'unique_sessions' => 10,
'avg_position' => 1.4,
'created_at' => $now,
'updated_at' => $now,
],
[
'metric_date' => $now->toDateString(),
'surface' => 'related_chip',
'tag_slug' => 'gamma',
'source_tag_slug' => 'primary',
'query' => '',
'clicks' => 4,
'unique_users' => 0,
'unique_sessions' => 3,
'avg_position' => 2.2,
'created_at' => $now,
'updated_at' => $now,
],
]);
$primary = \App\Models\Tag::query()->findOrFail(1);
$relatedTags = app(TagDiscoveryService::class)->relatedTags($primary, 8);
expect($relatedTags)->toHaveCount(2);
expect(isset($relatedTags[0]->slug))->toBeTrue();
expect($relatedTags[0]->slug)->toBe('beta');
expect((int) $relatedTags[0]->shared_artworks_count)->toBe(2);
expect((int) $relatedTags[0]->transition_clicks)->toBe(25);
expect(collect($relatedTags)->pluck('slug')->all())->toBe(['beta', 'gamma']);
});
it('orders featured tags by recent clicks before usage count', function (): void {
$now = now();
DB::table('tags')->insert([
['id' => 1, 'name' => 'Legacy Heavy', 'slug' => 'legacy-heavy', 'usage_count' => 900, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 2, 'name' => 'Momentum First', 'slug' => 'momentum-first', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 3, 'name' => 'Quiet', 'slug' => 'quiet', 'usage_count' => 80, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
]);
DB::table('tag_interaction_daily_metrics')->insert([
[
'metric_date' => $now->toDateString(),
'surface' => 'tag_search',
'tag_slug' => 'legacy-heavy',
'source_tag_slug' => '',
'query' => 'legacy',
'clicks' => 8,
'unique_users' => 0,
'unique_sessions' => 4,
'avg_position' => 2.5,
'created_at' => $now,
'updated_at' => $now,
],
[
'metric_date' => $now->toDateString(),
'surface' => 'tag_search',
'tag_slug' => 'momentum-first',
'source_tag_slug' => '',
'query' => 'momentum',
'clicks' => 42,
'unique_users' => 0,
'unique_sessions' => 18,
'avg_position' => 1.2,
'created_at' => $now,
'updated_at' => $now,
],
]);
$featured = app(TagDiscoveryService::class)->featuredTags(3);
expect($featured)->toHaveCount(3);
expect(collect($featured)->pluck('slug')->all())->toBe(['momentum-first', 'legacy-heavy', 'quiet']);
expect((int) $featured[0]->recent_clicks)->toBe(42);
expect((int) $featured[1]->recent_clicks)->toBe(8);
});
it('fills rising tags from usage fallback without duplicating featured tags', function (): void {
$now = now();
DB::table('tags')->insert([
['id' => 1, 'name' => 'Featured Momentum', 'slug' => 'featured-momentum', 'usage_count' => 800, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 2, 'name' => 'Rising Momentum', 'slug' => 'rising-momentum', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 3, 'name' => 'Fallback Alpha', 'slug' => 'fallback-alpha', 'usage_count' => 450, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
['id' => 4, 'name' => 'Fallback Beta', 'slug' => 'fallback-beta', 'usage_count' => 300, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
]);
DB::table('tag_interaction_daily_metrics')->insert([
[
'metric_date' => $now->toDateString(),
'surface' => 'tag_search',
'tag_slug' => 'featured-momentum',
'source_tag_slug' => '',
'query' => 'featured',
'clicks' => 50,
'unique_users' => 0,
'unique_sessions' => 20,
'avg_position' => 1.1,
'created_at' => $now,
'updated_at' => $now,
],
[
'metric_date' => $now->toDateString(),
'surface' => 'tag_search',
'tag_slug' => 'rising-momentum',
'source_tag_slug' => '',
'query' => 'rising',
'clicks' => 12,
'unique_users' => 0,
'unique_sessions' => 6,
'avg_position' => 1.8,
'created_at' => $now,
'updated_at' => $now,
],
]);
$service = app(TagDiscoveryService::class);
$featured = $service->featuredTags(1);
$rising = $service->risingTags($featured, 3);
expect(collect($featured)->pluck('slug')->all())->toBe(['featured-momentum']);
expect(collect($rising)->pluck('slug')->all())->toBe(['rising-momentum', 'fallback-alpha', 'fallback-beta']);
expect(collect($rising)->contains(fn ($tag) => $tag->slug === 'featured-momentum'))->toBeFalse();
});