From 041fb911825d2833346c7c655b8cc1b2b88032f7 Mon Sep 17 00:00:00 2001 From: friday-bot Date: Thu, 30 Jul 2026 13:49:10 -0600 Subject: [PATCH] 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". --- .../Admin/DocumentAccessController.php | 79 +++++++++++++++++++ resources/js/Pages/Admin/DocumentAccess.vue | 30 ++++++- routes/web.php | 1 + 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Admin/DocumentAccessController.php b/app/Http/Controllers/Admin/DocumentAccessController.php index 5073a893..4cc8e6ae 100644 --- a/app/Http/Controllers/Admin/DocumentAccessController.php +++ b/app/Http/Controllers/Admin/DocumentAccessController.php @@ -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. diff --git a/resources/js/Pages/Admin/DocumentAccess.vue b/resources/js/Pages/Admin/DocumentAccess.vue index f914fc3c..8f232864 100644 --- a/resources/js/Pages/Admin/DocumentAccess.vue +++ b/resources/js/Pages/Admin/DocumentAccess.vue @@ -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()}`; +});