]> BookStack Code Mirror - bookstack/commitdiff
Timezones: Seperated out store & display timezones to two options
authorDan Brown <redacted>
Thu, 4 Sep 2025 14:06:58 +0000 (15:06 +0100)
committerDan Brown <redacted>
Thu, 4 Sep 2025 14:06:58 +0000 (15:06 +0100)
.env.example.complete
app/App/Providers/ViewTweaksServiceProvider.php
app/Config/app.php
app/Util/DateFormatter.php [new file with mode: 0644]
resources/views/entities/meta.blade.php
tests/Util/DateFormatterTest.php [new file with mode: 0644]

index 25687aaac383ad75d3d821d1b47f8c2032924e20..18e7bd00d9cf2a0ca8cb085aef4fba33f25acc23 100644 (file)
@@ -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/<APP_THEME> folder where BookStack UI
index 7115dcb51148c22471a802624e5fbcf52cb8f9af..6771e513fa61616da1d40bcb38a9f0b3948fd2d4 100644 (file)
@@ -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 "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
index b96d0bdb7885215ff65ac665b77591f37a23745d..40e542d3e1653100f786ecfd584f5b4ff8aa03d8 100644 (file)
@@ -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 (file)
index 0000000..489ed54
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace BookStack\Util;
+
+use Carbon\Carbon;
+
+class DateFormatter
+{
+    public function __construct(
+        protected string $displayTimezone,
+    ) {
+    }
+
+    public function isoWithTimezone(Carbon $date): string
+    {
+        $withDisplayTimezone = $date->clone()->setTimezone($this->displayTimezone);
+
+        return $withDisplayTimezone->format('Y-m-d H:i:s T');
+    }
+
+    public function relative(Carbon $date): string
+    {
+        return $date->diffForHumans();
+    }
+}
index 9d3c4b956a7d784feecb7f38abdc8f7d3a668d57..c9d301aa3545d2ee9ffe6088f70473a527995181 100644 (file)
@@ -31,7 +31,7 @@
             @icon('star')
             <div>
                 {!! trans('entities.meta_created_name', [
-                    'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>',
+                    'timeLength' => '<span title="'. $dates->isoWithTimezone($entity->created_at) .'">'. $dates->relative($entity->created_at) . '</span>',
                     'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>"
                 ]) !!}
             </div>
@@ -39,7 +39,7 @@
     @else
         <div class="entity-meta-item">
             @icon('star')
-            <span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span>
+            <span title="{{ $dates->isoWithTimezone($entity->created_at) }}">{{ trans('entities.meta_created', ['timeLength' => $dates->relative($entity->created_at)]) }}</span>
         </div>
     @endif
 
@@ -48,7 +48,7 @@
             @icon('edit')
             <div>
                 {!! trans('entities.meta_updated_name', [
-                    'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>',
+                    'timeLength' => '<span title="' . $dates->isoWithTimezone($entity->updated_at) .'">' . $dates->relative($entity->updated_at) .'</span>',
                     'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>"
                 ]) !!}
             </div>
@@ -56,7 +56,7 @@
     @elseif (!$entity->isA('revision'))
         <div class="entity-meta-item">
             @icon('edit')
-            <span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
+            <span title="{{ $dates->isoWithTimezone($entity->updated_at) }}">{{ trans('entities.meta_updated', ['timeLength' => $dates->relative($entity->updated_at)]) }}</span>
         </div>
     @endif
 
diff --git a/tests/Util/DateFormatterTest.php b/tests/Util/DateFormatterTest.php
new file mode 100644 (file)
index 0000000..c9004b5
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace Tests\Util;
+
+use BookStack\Util\DateFormatter;
+use Carbon\Carbon;
+use Tests\TestCase;
+
+class DateFormatterTest extends TestCase
+{
+    public function test_iso_with_timezone_alters_from_stored_to_display_timezone()
+    {
+        $formatter = new DateFormatter('Europe/London');
+        $dateTime = new Carbon('2020-06-01 12:00:00', 'UTC');
+
+        $result = $formatter->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);
+    }
+}