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
This commit is contained in:
Víctor Falcón 2026-04-16 11:36:57 +01:00 committed by GitHub
parent 473ac03088
commit fde5405777
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 202 additions and 7 deletions

View File

@ -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')) {

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\UpdateTimezoneRequest;
use Illuminate\Http\Response;
class TimezoneController extends Controller
{
public function update(UpdateTimezoneRequest $request): Response
{
$user = $request->user();
if ($user->timezone === null) {
$user->update(['timezone' => $request->validated('timezone')]);
}
return response()->noContent();
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTimezoneRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'timezone' => ['required', 'string', 'timezone'],
];
}
}

View File

@ -37,6 +37,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
'paywall_seen_at',
'currency_code',
'locale',
'timezone',
];
/**

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('timezone')->nullable()->after('locale');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('timezone');
});
}
};

View File

@ -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<SharedData>) => {
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<string, string>) ?? {},
@ -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<string, string>) ?? {},
);
void syncUserTimezone(pageProps);
});
void syncUserTimezone(initialPageProps);
root.render(
<StrictMode>
<EncryptionKeyProvider

View File

@ -18,6 +18,11 @@ interface RegisterProps {
}
export default function Register({ hideAuthButtons = false }: RegisterProps) {
const detectedTimezone =
typeof window !== 'undefined'
? Intl.DateTimeFormat().resolvedOptions().timeZone || ''
: '';
useEffect(() => {
if (hideAuthButtons) {
router.visit(login());
@ -48,6 +53,12 @@ export default function Register({ hideAuthButtons = false }: RegisterProps) {
>
{({ processing, errors }) => (
<>
<input
type="hidden"
name="timezone"
value={detectedTimezone}
/>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="name">{__('Name')}</Label>

View File

@ -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;

View File

@ -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');

View File

@ -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();

View File

@ -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')

View File

@ -0,0 +1,46 @@
<?php
use App\Models\User;
test('timezone can be backfilled for authenticated users without one', function () {
$user = User::factory()->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'));
});