From 17fc9ff4cb890a8433311e841dc5706db5ca79e4 Mon Sep 17 00:00:00 2001 From: denkfabrik-li <274324701+denkfabrik-li@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:32:06 +0200 Subject: [PATCH] Compare the transfers window against the column's own timezone resolveTransferRange() builds every boundary in the viewer's zone, which is right and deliberate: "last week" should end when their evening does. Its docblock then claims the instants "compare against the UTC column directly". They do not. The query builder formats a Carbon in whatever zone the object carries and drops the offset, so the viewer's midnight arrives at the database as a UTC string. For Asia/Tokyo, measured: the instant the window really starts 2026-08-21T15:00:00+00:00 what the query asked for 2026-08-22 00:00:00 Nine hours at each end, in the same direction: the first nine hours of the viewer's window are missing from the chart, and the last nine hours of somebody else's day are counted into it. Every zone east or west of UTC gets a chart that is quietly wrong at both edges, which is worse than one that is obviously wrong. The comparison now converts; the day cursor a few lines below does not, because that half genuinely is about the viewer's calendar and is what puts an evening upload on the right bar. One test, in Asia/Tokyo, with an upload in the first hour of the viewer's window. Without the fix it is missing from the chart. --- .../Http/Controllers/DashboardController.php | 16 ++++++++-- tests/Feature/Audit/DashboardTest.php | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/app/Modules/Audit/Http/Controllers/DashboardController.php b/app/Modules/Audit/Http/Controllers/DashboardController.php index b8931b45..9476f234 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 db13433a..342111e0 100644 --- a/tests/Feature/Audit/DashboardTest.php +++ b/tests/Feature/Audit/DashboardTest.php @@ -16,6 +16,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; @@ -152,6 +153,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);