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:
friday-bot 2026-07-30 13:49:10 -06:00
parent 04bd71aaaa
commit 041fb91182
3 changed files with 108 additions and 2 deletions

View file

@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentAccessController extends Controller
{
@ -53,6 +54,9 @@ class DocumentAccessController extends Controller
'totals' => [
'downloads' => $documents->sum('downloads'),
'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.
'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
* rows actually carry an IP.

View file

@ -19,7 +19,7 @@ interface Props {
documents: DocumentRow[];
daily: DailyRow[];
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;
range_predates_ip_data: boolean;
}
@ -49,6 +49,17 @@ const preset = (days: number) => {
// Scale the inline bar chart to the busiest day in range.
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>
<template>
@ -119,11 +130,17 @@ const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)))
/>
Include crawlers
</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>
<!-- 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">
<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>
@ -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-3xl font-bold text-gray-900 mt-2">{{ props.totals.documents_touched }}</p>
</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>
<!-- Caveat: unique visitors are not knowable before IP logging began -->

View file

@ -40,6 +40,7 @@ Route::prefix('admin')->name('admin.')->group(function () {
// Document access statistics
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
Route::get('/subscribers', [\App\Http\Controllers\Admin\SubscriberController::class, 'index'])->name('subscribers.index');