Timezones: Seperated out store & display timezones to two options

This commit is contained in:
Dan Brown
2025-09-04 15:06:58 +01:00
parent 242b7dfb1b
commit 579c1bf424
6 changed files with 90 additions and 9 deletions

View 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

View 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(); ?>";

View 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

View File

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

View 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

View File

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