diff --git a/app/Modules/Audit/Http/Controllers/DashboardController.php b/app/Modules/Audit/Http/Controllers/DashboardController.php index ba875a6b..fb63f522 100644 --- a/app/Modules/Audit/Http/Controllers/DashboardController.php +++ b/app/Modules/Audit/Http/Controllers/DashboardController.php @@ -155,8 +155,12 @@ class DashboardController extends Controller * * Every boundary is built in the viewer's zone, so "last week" ends * when their evening does and not at whatever hour UTC midnight falls - * on for them. The returned instants are still absolute — only the - * day edges moved — so they compare against the UTC column directly. + * on for them. The instants are absolute, but they carry that zone — + * and a Carbon handed to the query builder is formatted in its own + * zone, offset discarded, so comparing one against a UTC column asks + * a question nine hours out for a viewer in Tokyo. transferSeries() + * converts before it compares; the day cursor there keeps them as + * they are, because that half really is about the viewer's calendar. * * @return array{0: Carbon, 1: Carbon, 2: string} */ @@ -248,7 +252,13 @@ class DashboardController extends Controller $rows = ActivityLog::query() ->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)]) - ->whereBetween('created_at', [$from, $to]) + // In UTC, because that is what the column is. The query + // builder formats a Carbon in whatever zone the object holds + // and drops the offset, so passing the viewer's midnight + // straight in compares "2026-08-22 00:00:00" against a UTC + // column — nine hours of somebody else's day, at both ends, + // for a viewer in Tokyo. + ->whereBetween('created_at', [$from->copy()->utc(), $to->copy()->utc()]) ->get(['action', 'actor_type', 'created_at']) // Bucketed by the viewer's calendar day. Grouping on the UTC // one puts an evening upload from anywhere west of Greenwich diff --git a/tests/Feature/Audit/DashboardTest.php b/tests/Feature/Audit/DashboardTest.php index a3e03e53..5f549a68 100644 --- a/tests/Feature/Audit/DashboardTest.php +++ b/tests/Feature/Audit/DashboardTest.php @@ -17,6 +17,7 @@ use App\Modules\Platform\Capabilities\Edition; use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Settings; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia; @@ -153,6 +154,36 @@ test('the transfers widget honors the range selector', function () { ); }); +test('the transfers window is read in UTC, whatever zone the viewer is in', function () { + // The boundaries are built in the viewer's zone on purpose, but the + // column is UTC and the query builder formats a Carbon in its own zone + // with the offset thrown away. For Asia/Tokyo that asks for + // "2026-08-16 00:00:00" where the viewer's window really begins at + // 2026-08-15 15:00:00Z — the first nine hours of their week are + // missing from the chart. + $viewer = User::factory()->create(['timezone' => 'Asia/Tokyo']); + $file = File::factory()->create(['uploaded_by' => $viewer->id, 'name' => 'early upload']); + + // 11:00 on the 22nd in Tokyo. "Last week" is their 16th to their 22nd. + $this->travelTo(Carbon::parse('2026-08-22 02:00:00', 'UTC')); + + // 01:00 on the 16th in Tokyo: the first hour of the window, and before + // "2026-08-16 00:00:00" read as UTC. + ActivityLog::query()->create([ + 'actor_id' => $viewer->id, 'actor_name' => $viewer->name, 'actor_type' => 'staff', + 'action' => Action::FileUploaded, 'subject_type' => $file->getMorphClass(), + 'subject_id' => $file->id, 'subject_name' => $file->name, + 'created_at' => Carbon::parse('2026-08-15 16:00:00', 'UTC'), + ]); + + $props = $this->actingAs($viewer)->get('/dashboard?range=last_week')->assertOk()->viewData('page')['props']; + + expect(collect($props['transfers'])->sum('uploads'))->toBe(1) + ->and(collect($props['transfers'])->firstWhere('date', '2026-08-16')['uploads'])->toBe(1); + + $this->travelBack(); +}); + test('the top-clients-by-storage widget ranks clients by usage against their effective quota', function () { app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 200);