Add unique visitor total and CSV export to document access
Surfaces distinct visitors for the selected range as a headline figure, and lets the current view be exported for use in filings without retyping. Unique visitors are counted across the range as a whole rather than summed per document, so one person pulling five filings counts once. Where visitor IPs were never recorded the CSV leaves the cell blank instead of writing 0, which would otherwise read as "nobody downloaded this".
This commit is contained in:
parent
04bd71aaaa
commit
041fb91182
3 changed files with 108 additions and 2 deletions
|
|
@ -9,6 +9,7 @@ use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class DocumentAccessController extends Controller
|
class DocumentAccessController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -53,6 +54,9 @@ class DocumentAccessController extends Controller
|
||||||
'totals' => [
|
'totals' => [
|
||||||
'downloads' => $documents->sum('downloads'),
|
'downloads' => $documents->sum('downloads'),
|
||||||
'documents_touched' => $documents->count(),
|
'documents_touched' => $documents->count(),
|
||||||
|
// Distinct across the whole range, not the sum of the per-document
|
||||||
|
// column: one person pulling five documents is one visitor.
|
||||||
|
'unique_visitors' => $this->uniqueVisitors($from, $to, $includeBots),
|
||||||
],
|
],
|
||||||
// Drives the caveat shown in the UI.
|
// Drives the caveat shown in the UI.
|
||||||
'ip_data_starts' => self::IP_DATA_STARTS,
|
'ip_data_starts' => self::IP_DATA_STARTS,
|
||||||
|
|
@ -60,6 +64,81 @@ class DocumentAccessController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export the current view as CSV, so figures can be cited or filed
|
||||||
|
* without retyping them.
|
||||||
|
*/
|
||||||
|
public function export(Request $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date'],
|
||||||
|
'to' => ['nullable', 'date'],
|
||||||
|
'include_bots' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = isset($validated['from'])
|
||||||
|
? Carbon::parse($validated['from'])->startOfDay()
|
||||||
|
: Carbon::now()->subDays(60)->startOfDay();
|
||||||
|
|
||||||
|
$to = isset($validated['to'])
|
||||||
|
? Carbon::parse($validated['to'])->endOfDay()
|
||||||
|
: Carbon::now()->endOfDay();
|
||||||
|
|
||||||
|
$includeBots = (bool) ($validated['include_bots'] ?? false);
|
||||||
|
$documents = $this->documentCounts($from, $to, $includeBots);
|
||||||
|
$ipDataStarts = Carbon::parse(self::IP_DATA_STARTS);
|
||||||
|
|
||||||
|
$filename = sprintf(
|
||||||
|
'document-access-%s-to-%s.csv',
|
||||||
|
$from->toDateString(),
|
||||||
|
$to->toDateString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($documents, $from, $ipDataStarts) {
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
fputcsv($out, ['Document', 'Downloads', 'Unique visitors', 'Last accessed']);
|
||||||
|
|
||||||
|
foreach ($documents as $row) {
|
||||||
|
fputcsv($out, [
|
||||||
|
$row['title'],
|
||||||
|
$row['downloads'],
|
||||||
|
// Blank rather than 0 where the figure is unknowable, so the
|
||||||
|
// spreadsheet cannot be read as "nobody downloaded this".
|
||||||
|
$row['unique_visitors'] === 0 ? '' : $row['unique_visitors'],
|
||||||
|
$row['last_accessed'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($from->lt($ipDataStarts)) {
|
||||||
|
fputcsv($out, []);
|
||||||
|
fputcsv($out, [
|
||||||
|
'Note: visitor IP addresses were only recorded from '
|
||||||
|
.$ipDataStarts->toDateString()
|
||||||
|
.'. Unique visitors are blank for earlier periods.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($out);
|
||||||
|
}, $filename, ['Content-Type' => 'text/csv']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct visitors across the range as a whole.
|
||||||
|
*/
|
||||||
|
private function uniqueVisitors(Carbon $from, Carbon $to, bool $includeBots): int
|
||||||
|
{
|
||||||
|
$query = DocumentAccess::query()
|
||||||
|
->whereBetween('accessed_at', [$from, $to])
|
||||||
|
->whereNotNull('ip_address');
|
||||||
|
|
||||||
|
if (! $includeBots) {
|
||||||
|
$query->excludingBots();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $query->distinct()->count('ip_address');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download counts per document, plus unique visitors where the underlying
|
* Download counts per document, plus unique visitors where the underlying
|
||||||
* rows actually carry an IP.
|
* rows actually carry an IP.
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ interface Props {
|
||||||
documents: DocumentRow[];
|
documents: DocumentRow[];
|
||||||
daily: DailyRow[];
|
daily: DailyRow[];
|
||||||
filters: { from: string; to: string; include_bots: boolean };
|
filters: { from: string; to: string; include_bots: boolean };
|
||||||
totals: { downloads: number; documents_touched: number };
|
totals: { downloads: number; documents_touched: number; unique_visitors: number };
|
||||||
ip_data_starts: string;
|
ip_data_starts: string;
|
||||||
range_predates_ip_data: boolean;
|
range_predates_ip_data: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +49,17 @@ const preset = (days: number) => {
|
||||||
|
|
||||||
// Scale the inline bar chart to the busiest day in range.
|
// Scale the inline bar chart to the busiest day in range.
|
||||||
const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)));
|
const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)));
|
||||||
|
|
||||||
|
// Keep the CSV in step with whatever range is currently on screen.
|
||||||
|
const exportUrl = computed(() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
from: from.value,
|
||||||
|
to: to.value,
|
||||||
|
include_bots: includeBots.value ? '1' : '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${route('admin.document-access.export')}?${params.toString()}`;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -119,11 +130,17 @@ const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)))
|
||||||
/>
|
/>
|
||||||
Include crawlers
|
Include crawlers
|
||||||
</label>
|
</label>
|
||||||
|
<a
|
||||||
|
:href="exportUrl"
|
||||||
|
class="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary -->
|
<!-- Summary -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<p class="text-sm font-medium text-gray-600">Total downloads</p>
|
<p class="text-sm font-medium text-gray-600">Total downloads</p>
|
||||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.downloads }}</p>
|
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.downloads }}</p>
|
||||||
|
|
@ -132,6 +149,15 @@ const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)))
|
||||||
<p class="text-sm font-medium text-gray-600">Documents downloaded</p>
|
<p class="text-sm font-medium text-gray-600">Documents downloaded</p>
|
||||||
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.documents_touched }}</p>
|
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.documents_touched }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<p class="text-sm font-medium text-gray-600">Unique visitors</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-2">
|
||||||
|
{{ props.totals.unique_visitors === 0 ? '—' : props.totals.unique_visitors }}
|
||||||
|
</p>
|
||||||
|
<p v-if="props.range_predates_ip_data" class="text-xs text-gray-500 mt-1">
|
||||||
|
Only counted from {{ props.ip_data_starts }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Caveat: unique visitors are not knowable before IP logging began -->
|
<!-- Caveat: unique visitors are not knowable before IP logging began -->
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ Route::prefix('admin')->name('admin.')->group(function () {
|
||||||
|
|
||||||
// Document access statistics
|
// Document access statistics
|
||||||
Route::get('/document-access', [\App\Http\Controllers\Admin\DocumentAccessController::class, 'index'])->name('document-access.index');
|
Route::get('/document-access', [\App\Http\Controllers\Admin\DocumentAccessController::class, 'index'])->name('document-access.index');
|
||||||
|
Route::get('/document-access/export', [\App\Http\Controllers\Admin\DocumentAccessController::class, 'export'])->name('document-access.export');
|
||||||
|
|
||||||
// Subscriber routes
|
// Subscriber routes
|
||||||
Route::get('/subscribers', [\App\Http\Controllers\Admin\SubscriberController::class, 'index'])->name('subscribers.index');
|
Route::get('/subscribers', [\App\Http\Controllers\Admin\SubscriberController::class, 'index'])->name('subscribers.index');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue