From 579c1bf4241270f96f527101f89a2c90c9b9cb6b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 4 Sep 2025 15:06:58 +0100 Subject: [PATCH] Timezones: Seperated out store & display timezones to two options --- .env.example.complete | 8 +++- .../Providers/ViewTweaksServiceProvider.php | 13 +++++++ app/Config/app.php | 8 ++-- app/Util/DateFormatter.php | 25 +++++++++++++ resources/views/entities/meta.blade.php | 8 ++-- tests/Util/DateFormatterTest.php | 37 +++++++++++++++++++ 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 app/Util/DateFormatter.php create mode 100644 tests/Util/DateFormatterTest.php diff --git a/.env.example.complete b/.env.example.complete index 25687aaac..18e7bd00d 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -36,10 +36,14 @@ APP_LANG=en # APP_LANG will be used if such a header is not provided. APP_AUTO_LANG_PUBLIC=true -# Application timezone -# Used where dates are displayed such as on exported content. +# Application timezones +# The first option is used to determine what timezone is used for date storage. +# Leaving that as "UTC" is advised. +# The second option is used to set the timezone which will be used for date +# formatting and display. This defaults to the "APP_TIMEZONE" value. # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php APP_TIMEZONE=UTC +APP_DISPLAY_TIMEZONE=UTC # Application theme # Used to specific a themes/ folder where BookStack UI diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php index 7115dcb51..6771e513f 100644 --- a/app/App/Providers/ViewTweaksServiceProvider.php +++ b/app/App/Providers/ViewTweaksServiceProvider.php @@ -3,6 +3,7 @@ namespace BookStack\App\Providers; use BookStack\Entities\BreadcrumbsViewComposer; +use BookStack\Util\DateFormatter; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View; @@ -10,6 +11,15 @@ use Illuminate\Support\ServiceProvider; class ViewTweaksServiceProvider extends ServiceProvider { + public function register() + { + $this->app->singleton(DateFormatter::class, function ($app) { + return new DateFormatter( + $app['config']->get('app.display_timezone'), + ); + }); + } + /** * Bootstrap services. */ @@ -21,6 +31,9 @@ class ViewTweaksServiceProvider extends ServiceProvider // View Composers View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); + // View Globals + View::share('dates', $this->app->make(DateFormatter::class)); + // Custom blade view directives Blade::directive('icon', function ($expression) { return "toHtml(); ?>"; diff --git a/app/Config/app.php b/app/Config/app.php index b96d0bdb7..40e542d3e 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -70,8 +70,8 @@ return [ // A list of the sources/hostnames that can be reached by application SSR calls. // This is used wherever users can provide URLs/hosts in-platform, like for webhooks. // Host-specific functionality (usually controlled via other options) like auth - // or user avatars for example, won't use this list. - // Space seperated if multiple. Can use '*' as a wildcard. + // or user avatars, for example, won't use this list. + // Space separated if multiple. Can use '*' as a wildcard. // Values will be compared prefix-matched, case-insensitive, against called SSR urls. // Defaults to allow all hosts. 'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'), @@ -80,8 +80,10 @@ return [ // Integer value between 0 (IP hidden) to 4 (Full IP usage) 'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4), - // Application timezone for back-end date functions. + // Application timezone for stored date/time values. 'timezone' => env('APP_TIMEZONE', 'UTC'), + // Application timezone for displayed date/time values in the UI. + 'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')), // Default locale to use // A default variant is also stored since Laravel can overwrite diff --git a/app/Util/DateFormatter.php b/app/Util/DateFormatter.php new file mode 100644 index 000000000..489ed54e2 --- /dev/null +++ b/app/Util/DateFormatter.php @@ -0,0 +1,25 @@ +clone()->setTimezone($this->displayTimezone); + + return $withDisplayTimezone->format('Y-m-d H:i:s T'); + } + + public function relative(Carbon $date): string + { + return $date->diffForHumans(); + } +} diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php index 9d3c4b956..c9d301aa3 100644 --- a/resources/views/entities/meta.blade.php +++ b/resources/views/entities/meta.blade.php @@ -31,7 +31,7 @@ @icon('star')
{!! trans('entities.meta_created_name', [ - 'timeLength' => ''.$entity->created_at->diffForHumans() . '', + 'timeLength' => ''. $dates->relative($entity->created_at) . '', 'user' => "".e($entity->createdBy->name). "" ]) !!}
@@ -39,7 +39,7 @@ @else
@icon('star') - {{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }} + {{ trans('entities.meta_created', ['timeLength' => $dates->relative($entity->created_at)]) }}
@endif @@ -48,7 +48,7 @@ @icon('edit')
{!! trans('entities.meta_updated_name', [ - 'timeLength' => '' . $entity->updated_at->diffForHumans() .'', + 'timeLength' => '' . $dates->relative($entity->updated_at) .'', 'user' => "".e($entity->updatedBy->name). "" ]) !!}
@@ -56,7 +56,7 @@ @elseif (!$entity->isA('revision'))
@icon('edit') - {{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }} + {{ trans('entities.meta_updated', ['timeLength' => $dates->relative($entity->updated_at)]) }}
@endif diff --git a/tests/Util/DateFormatterTest.php b/tests/Util/DateFormatterTest.php new file mode 100644 index 000000000..c9004b5a5 --- /dev/null +++ b/tests/Util/DateFormatterTest.php @@ -0,0 +1,37 @@ +isoWithTimezone($dateTime); + $this->assertEquals('2020-06-01 13:00:00 BST', $result); + } + + public function test_iso_with_timezone_works_from_non_utc_dates() + { + $formatter = new DateFormatter('Asia/Shanghai'); + $dateTime = new Carbon('2025-06-10 15:25:00', 'America/New_York'); + + $result = $formatter->isoWithTimezone($dateTime); + $this->assertEquals('2025-06-11 03:25:00 CST', $result); + } + + public function test_relative() + { + $formatter = new DateFormatter('Europe/London'); + $dateTime = (new Carbon('now', 'UTC'))->subMinutes(50); + + $result = $formatter->relative($dateTime); + $this->assertEquals('50 minutes ago', $result); + } +}