Merge pull request #1737 from denkfabrik-li/fix/transfer-range-utc-bounds

resolveTransferRange() builds every boundary in the viewer's zone, deliberately: "last week" should end when their evening does, not at whatever hour UTC midnight falls on for them. Its docblock then claimed the instants "compare against the UTC column directly". They did not -- the query builder formats a Carbon in whatever zone the object carries and discards the offset, so the viewer's midnight reached the database as a UTC string. For Asia/Tokyo the window really began at 2026-08-21T15:00:00Z while the query asked for 2026-08-22 00:00:00: nine hours at each end, both in the same direction, so the first nine hours of the viewer's window were missing from the chart and the last nine hours of somebody else's day were counted into it.

The comparison now converts to UTC, one ->copy()->utc() per boundary. The copy matters: the originals keep the viewer's zone, so the day cursor and the grouping below still put an evening upload on the right bar, which is the half that really is about the viewer's calendar. Every other date filter already goes through LocalDay::start()/end(), which return UTC, which is why the activity log and the download history never had this.

Verified before merging: 20 passed on the trial-merge, 1 failed / 19 passed with app/ reset. Shares DashboardController and its test file with #1722, already merged, so the merged tree was checked -- that PR's visibleToClient change is intact.

Reported and fixed by @denkfabrik-li.
This commit is contained in:
ignacionelson
2026-08-28 17:22:37 -03:00
2 changed files with 44 additions and 3 deletions
@@ -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
+31
View File
@@ -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);