From fde5405777250f71cdcc1b45fae73fdb64cd7496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 16 Apr 2026 11:36:57 +0100 Subject: [PATCH] fix(user): persist detected timezones (#296) ## Summary - store browser-detected IANA timezones on users during registration and share the value to the frontend - silently backfill missing timezones for authenticated users without overwriting existing saved values - add focused feature coverage for registration, shared props, and the timezone backfill endpoint ## Testing - php artisan test --compact tests/Feature/Auth/RegistrationTest.php tests/Feature/InertiaSharedDataTest.php tests/Feature/Settings/TimezoneTest.php - npm test -- resources/js/utils/currency.test.ts --- app/Actions/Fortify/CreateNewUser.php | 2 + .../Settings/TimezoneController.php | 21 +++++++++ .../Settings/UpdateTimezoneRequest.php | 20 ++++++++ app/Models/User.php | 1 + ..._16_092644_add_timezone_to_users_table.php | 28 +++++++++++ resources/js/app.tsx | 33 ++++++++++++- resources/js/pages/auth/register.tsx | 11 +++++ resources/js/types/index.d.ts | 1 + routes/settings.php | 2 + tests/Feature/Auth/RegistrationTest.php | 33 ++++++++++++- tests/Feature/InertiaSharedDataTest.php | 11 +++-- tests/Feature/Settings/TimezoneTest.php | 46 +++++++++++++++++++ 12 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/Settings/TimezoneController.php create mode 100644 app/Http/Requests/Settings/UpdateTimezoneRequest.php create mode 100644 database/migrations/2026_04_16_092644_add_timezone_to_users_table.php create mode 100644 tests/Feature/Settings/TimezoneTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 611b3f1d..2589ac40 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -28,6 +28,7 @@ class CreateNewUser implements CreatesNewUsers Rule::unique(User::class), ], 'password' => $this->passwordRules(), + 'timezone' => ['nullable', 'string', 'timezone'], ])->validate(); $user = User::create([ @@ -35,6 +36,7 @@ class CreateNewUser implements CreatesNewUsers 'email' => $input['email'], 'password' => $input['password'], 'locale' => $this->detectLocaleFromRequest(), + 'timezone' => $input['timezone'] ?? null, ]); if (! config('mail.email_verification_enabled')) { diff --git a/app/Http/Controllers/Settings/TimezoneController.php b/app/Http/Controllers/Settings/TimezoneController.php new file mode 100644 index 00000000..719046a4 --- /dev/null +++ b/app/Http/Controllers/Settings/TimezoneController.php @@ -0,0 +1,21 @@ +user(); + + if ($user->timezone === null) { + $user->update(['timezone' => $request->validated('timezone')]); + } + + return response()->noContent(); + } +} diff --git a/app/Http/Requests/Settings/UpdateTimezoneRequest.php b/app/Http/Requests/Settings/UpdateTimezoneRequest.php new file mode 100644 index 00000000..820ba1ac --- /dev/null +++ b/app/Http/Requests/Settings/UpdateTimezoneRequest.php @@ -0,0 +1,20 @@ + ['required', 'string', 'timezone'], + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 938abc51..7f9f3fe8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -37,6 +37,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'paywall_seen_at', 'currency_code', 'locale', + 'timezone', ]; /** diff --git a/database/migrations/2026_04_16_092644_add_timezone_to_users_table.php b/database/migrations/2026_04_16_092644_add_timezone_to_users_table.php new file mode 100644 index 00000000..4ef97f51 --- /dev/null +++ b/database/migrations/2026_04_16_092644_add_timezone_to_users_table.php @@ -0,0 +1,28 @@ +string('timezone')->nullable()->after('locale'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('timezone'); + }); + } +}; diff --git a/resources/js/app.tsx b/resources/js/app.tsx index d1038f4b..e493021a 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -2,6 +2,7 @@ import '../css/app.css'; import { createInertiaApp, router } from '@inertiajs/react'; import * as Sentry from '@sentry/react'; +import axios from 'axios'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { CircleCheckIcon, @@ -37,6 +38,7 @@ initializeTheme(); initializeChartColorScheme(); const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; +let hasAttemptedTimezoneBackfill = false; // Determine progress bar color based on current theme const getProgressBarColor = () => { @@ -65,6 +67,31 @@ createInertiaApp({ const hasEncryptedTransactions = (initialPageProps?.hasEncryptedTransactions as boolean) ?? false; + const syncUserTimezone = async (pageProps?: Partial) => { + const user = pageProps?.auth?.user ?? null; + const detectedTimezone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + + if ( + hasAttemptedTimezoneBackfill || + !user || + user.timezone || + !detectedTimezone + ) { + return; + } + + hasAttemptedTimezoneBackfill = true; + + try { + await axios.patch('/settings/timezone', { + timezone: detectedTimezone, + }); + } catch { + hasAttemptedTimezoneBackfill = false; + } + }; + // Initialize translations from server-rendered page data setTranslations( (initialPageProps?.translations as Record) ?? {}, @@ -72,12 +99,16 @@ createInertiaApp({ // Keep translations in sync on every Inertia navigation router.on('navigate', (event) => { - const pageProps = event.detail.page.props as SharedData; + const pageProps = event.detail.page.props as unknown as SharedData; setTranslations( (pageProps?.translations as Record) ?? {}, ); + + void syncUserTimezone(pageProps); }); + void syncUserTimezone(initialPageProps); + root.render( { if (hideAuthButtons) { router.visit(login()); @@ -48,6 +53,12 @@ export default function Register({ hideAuthButtons = false }: RegisterProps) { > {({ processing, errors }) => ( <> + +
diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 58e800da..2c639a87 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -84,6 +84,7 @@ export interface User { email: string; currency_code: CurrencyCode; locale: string | null; + timezone: string | null; avatar?: string; email_verified_at: string | null; two_factor_enabled?: boolean; diff --git a/routes/settings.php b/routes/settings.php index 279508cf..cff17a62 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController; use App\Http\Controllers\Settings\NetWorthChartRealEstatePreferenceController; use App\Http\Controllers\Settings\PasswordController; use App\Http\Controllers\Settings\ProfileController; +use App\Http\Controllers\Settings\TimezoneController; use App\Http\Controllers\Settings\TwoFactorAuthenticationController; use App\Http\Controllers\SubscriptionController; use Illuminate\Support\Facades\Route; @@ -21,6 +22,7 @@ Route::middleware('auth')->group(function () { Route::get('settings/account', [ProfileController::class, 'account'])->name('account.edit'); Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); + Route::patch('settings/timezone', [TimezoneController::class, 'update'])->name('timezone.update'); Route::delete('settings/profile', [ProfileController::class, 'destroy']) ->middleware('block-demo') ->name('profile.destroy'); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 0bf3f732..169df993 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; test('registration screen can be rendered', function () { - $response = $this->get(route('register')); + $response = $this->withoutVite()->get(route('register')); $response->assertSuccessful(); }); @@ -25,6 +25,37 @@ test('new users can register', function () { $response->assertRedirect(route('onboarding', absolute: false)); }); +test('new users store their detected timezone on registration', function () { + Queue::fake(); + + $this->post(route('register.store'), [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'timezone' => 'America/New_York', + ]); + + $user = User::where('email', 'test@example.com')->first(); + + expect($user->timezone)->toBe('America/New_York'); +}); + +test('new users can register without a timezone', function () { + Queue::fake(); + + $this->post(route('register.store'), [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $user = User::where('email', 'test@example.com')->first(); + + expect($user->timezone)->toBeNull(); +}); + test('new users receive a verification email on registration', function () { Notification::fake(); diff --git a/tests/Feature/InertiaSharedDataTest.php b/tests/Feature/InertiaSharedDataTest.php index a0d74ede..9b90e32b 100644 --- a/tests/Feature/InertiaSharedDataTest.php +++ b/tests/Feature/InertiaSharedDataTest.php @@ -6,7 +6,7 @@ use Inertia\Testing\AssertableInertia as Assert; use function Pest\Laravel\actingAs; test('guests receive null auth user in shared props', function () { - $response = $this->get(route('home')); + $response = $this->withoutVite()->get(route('home')); $response->assertInertia(fn (Assert $page) => $page ->where('auth.user', null) @@ -14,18 +14,19 @@ test('guests receive null auth user in shared props', function () { }); test('authenticated users receive auth user in shared props', function () { - $user = User::factory()->create(); + $user = User::factory()->create(['timezone' => 'Europe/Madrid']); - $response = actingAs($user)->get(route('home')); + $response = actingAs($user)->withoutVite()->get(route('home')); $response->assertInertia(fn (Assert $page) => $page ->where('auth.user.id', $user->id) ->where('auth.user.email', $user->email) + ->where('auth.user.timezone', 'Europe/Madrid') ); }); test('all pages receive app url in shared props', function () { - $response = $this->get(route('home')); + $response = $this->withoutVite()->get(route('home')); $response->assertInertia(fn (Assert $page) => $page ->where('appUrl', config('app.url')) @@ -33,7 +34,7 @@ test('all pages receive app url in shared props', function () { }); test('shared currency options split profile and account currencies', function () { - $response = $this->get(route('home')); + $response = $this->withoutVite()->get(route('home')); $response->assertInertia(fn (Assert $page) => $page ->where('currencies.profile.0.code', 'USD') diff --git a/tests/Feature/Settings/TimezoneTest.php b/tests/Feature/Settings/TimezoneTest.php new file mode 100644 index 00000000..7301ebe9 --- /dev/null +++ b/tests/Feature/Settings/TimezoneTest.php @@ -0,0 +1,46 @@ +create(['timezone' => null]); + + $response = $this->actingAs($user)->patchJson(route('timezone.update'), [ + 'timezone' => 'Europe/Madrid', + ]); + + $response->assertNoContent(); + + expect($user->refresh()->timezone)->toBe('Europe/Madrid'); +}); + +test('timezone backfill does not overwrite an existing timezone', function () { + $user = User::factory()->create(['timezone' => 'America/New_York']); + + $response = $this->actingAs($user)->patchJson(route('timezone.update'), [ + 'timezone' => 'Europe/Madrid', + ]); + + $response->assertNoContent(); + + expect($user->refresh()->timezone)->toBe('America/New_York'); +}); + +test('timezone backfill rejects invalid timezones', function () { + $user = User::factory()->create(['timezone' => null]); + + $response = $this->actingAs($user)->patchJson(route('timezone.update'), [ + 'timezone' => 'Mars/Olympus_Mons', + ]); + + $response->assertJsonValidationErrors(['timezone']); + expect($user->refresh()->timezone)->toBeNull(); +}); + +test('timezone backfill requires authentication', function () { + $response = $this->patch(route('timezone.update'), [ + 'timezone' => 'Europe/Madrid', + ]); + + $response->assertRedirect(route('login')); +});