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

@@ -2,9 +2,23 @@
@section('content')
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
<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>
<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 class="space-y-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 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>
</div>
</div>
</div>
</div>
@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