fix(#124): improve timezone support
Some checks failed
tests / ci (push) Has been cancelled

This commit is contained in:
Benjamin Nussbaum 2025-12-02 16:44:41 +01:00
parent 0322ec899e
commit b10bbca774
9 changed files with 168 additions and 12 deletions

View file

@ -121,6 +121,10 @@ class GenerateDefaultImagesCommand extends Command
$browserStage = new BrowserStage($browsershotInstance); $browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($html); $browserStage->html($html);
// Set timezone from app config (no user context in this command)
$browserStage->timezone(config('app.timezone'));
$browserStage $browserStage
->width($deviceModel->width) ->width($deviceModel->width)
->height($deviceModel->height); ->height($deviceModel->height);

View file

@ -12,6 +12,7 @@ use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness; use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag; use App\Liquid\Tags\TemplateTag;
use App\Services\PluginImportService; use App\Services\PluginImportService;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -454,6 +455,12 @@ class Plugin extends Model
$renderedContent = ''; $renderedContent = '';
if ($this->markup_language === 'liquid') { if ($this->markup_language === 'liquid') {
// Get timezone from user or fall back to app timezone
$timezone = $this->user->timezone ?? config('app.timezone');
// Calculate UTC offset in seconds
$utcOffset = (string) Carbon::now($timezone)->getOffset();
// Build render context // Build render context
$context = [ $context = [
'size' => $size, 'size' => $size,
@ -465,10 +472,10 @@ class Plugin extends Model
'timestamp_utc' => now()->utc()->timestamp, 'timestamp_utc' => now()->utc()->timestamp,
], ],
'user' => [ 'user' => [
'utc_offset' => '0', 'utc_offset' => $utcOffset,
'name' => $this->user->name ?? 'Unknown User', 'name' => $this->user->name ?? 'Unknown User',
'locale' => 'en', 'locale' => 'en',
'time_zone_iana' => config('app.timezone'), 'time_zone_iana' => $timezone,
], ],
'plugin_settings' => [ 'plugin_settings' => [
'instance_name' => $this->name, 'instance_name' => $this->name,

View file

@ -27,6 +27,7 @@ class User extends Authenticatable // implements MustVerifyEmail
'assign_new_devices', 'assign_new_devices',
'assign_new_device_id', 'assign_new_device_id',
'oidc_sub', 'oidc_sub',
'timezone',
]; ];
/** /**

View file

@ -25,7 +25,7 @@ class ImageGenerationService
{ {
public static function generateImage(string $markup, $deviceId): string public static function generateImage(string $markup, $deviceId): string
{ {
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette'])->find($deviceId); $device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
$uuid = Uuid::uuid4()->toString(); $uuid = Uuid::uuid4()->toString();
try { try {
@ -44,6 +44,10 @@ class ImageGenerationService
$browserStage = new BrowserStage($browsershotInstance); $browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($markup); $browserStage->html($markup);
// Set timezone from user or fall back to app timezone
$timezone = $device->user->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') { if (config('app.puppeteer_window_size_strategy') === 'v2') {
$browserStage $browserStage
->width($imageSettings['width']) ->width($imageSettings['width'])
@ -352,7 +356,7 @@ class ImageGenerationService
try { try {
// Load device with relationships // Load device with relationships
$device->load(['palette', 'deviceModel.palette']); $device->load(['palette', 'deviceModel.palette', 'user']);
// Get image generation settings from DeviceModel if available, otherwise use device settings // Get image generation settings from DeviceModel if available, otherwise use device settings
$imageSettings = self::getImageSettings($device); $imageSettings = self::getImageSettings($device);
@ -372,6 +376,10 @@ class ImageGenerationService
$browserStage = new BrowserStage($browsershotInstance); $browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($html); $browserStage->html($html);
// Set timezone from user or fall back to app timezone
$timezone = $device->user->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') { if (config('app.puppeteer_window_size_strategy') === 'v2') {
$browserStage $browserStage
->width($imageSettings['width']) ->width($imageSettings['width'])

View file

@ -15,7 +15,7 @@
"ext-simplexml": "*", "ext-simplexml": "*",
"ext-zip": "*", "ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "2.0.*", "bnussbau/laravel-trmnl-blade": "2.0.*",
"bnussbau/trmnl-pipeline-php": "^0.5.0", "bnussbau/trmnl-pipeline-php": "^0.6.0",
"keepsuit/laravel-liquid": "^0.5.2", "keepsuit/laravel-liquid": "^0.5.2",
"laravel/framework": "^12.1", "laravel/framework": "^12.1",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",

14
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "38e8a7dd90ccc1b777a4c8a5a28f9f14", "content-hash": "7750ff686c4cad7f85390488c28b33ca",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -243,16 +243,16 @@
}, },
{ {
"name": "bnussbau/trmnl-pipeline-php", "name": "bnussbau/trmnl-pipeline-php",
"version": "0.5.0", "version": "0.6.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/bnussbau/trmnl-pipeline-php.git", "url": "https://github.com/bnussbau/trmnl-pipeline-php.git",
"reference": "eb55b89e1f3991764912505872bbce809874d1aa" "reference": "228505afa8a39a5033916e9ae5ccbf1733092c1f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/bnussbau/trmnl-pipeline-php/zipball/eb55b89e1f3991764912505872bbce809874d1aa", "url": "https://api.github.com/repos/bnussbau/trmnl-pipeline-php/zipball/228505afa8a39a5033916e9ae5ccbf1733092c1f",
"reference": "eb55b89e1f3991764912505872bbce809874d1aa", "reference": "228505afa8a39a5033916e9ae5ccbf1733092c1f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -294,7 +294,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/bnussbau/trmnl-pipeline-php/issues", "issues": "https://github.com/bnussbau/trmnl-pipeline-php/issues",
"source": "https://github.com/bnussbau/trmnl-pipeline-php/tree/0.5.0" "source": "https://github.com/bnussbau/trmnl-pipeline-php/tree/0.6.0"
}, },
"funding": [ "funding": [
{ {
@ -310,7 +310,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-11-25T17:00:21+00:00" "time": "2025-12-02T15:18:51+00:00"
}, },
{ {
"name": "brick/math", "name": "brick/math",

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('oidc_sub');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('timezone');
});
}
};

View file

@ -11,9 +11,12 @@ use Livewire\Volt\Component;
new class extends Component { new class extends Component {
public ?int $assign_new_device_id = null; public ?int $assign_new_device_id = null;
public ?string $timezone = null;
public function mount(): void public function mount(): void
{ {
$this->assign_new_device_id = Auth::user()->assign_new_device_id; $this->assign_new_device_id = Auth::user()->assign_new_device_id;
$this->timezone = Auth::user()->timezone ?? config('app.timezone');
} }
public function updatePreferences(): void public function updatePreferences(): void
@ -26,6 +29,11 @@ new class extends Component {
->whereNull('mirror_device_id'); ->whereNull('mirror_device_id');
}), }),
], ],
'timezone' => [
'nullable',
'string',
Rule::in(timezone_identifiers_list()),
],
]); ]);
Auth::user()->update($validated); Auth::user()->update($validated);
@ -39,6 +47,14 @@ new class extends Component {
<x-settings.layout heading="Preferences" subheading="Update your preferences"> <x-settings.layout heading="Preferences" subheading="Update your preferences">
<form wire:submit="updatePreferences" class="my-6 w-full space-y-6"> <form wire:submit="updatePreferences" class="my-6 w-full space-y-6">
<flux:select wire:model="timezone" label="Timezone">
<flux:select.option value="" disabled>Select timezone...</flux:select.option>
@foreach(timezone_identifiers_list() as $tz)
<flux:select.option value="{{ $tz }}">{{ $tz }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model="assign_new_device_id" label="Auto-Joined Devices should mirror"> <flux:select wire:model="assign_new_device_id" label="Auto-Joined Devices should mirror">
<flux:select.option value="">None</flux:select.option> <flux:select.option value="">None</flux:select.option>
@foreach(auth()->user()->devices->where('mirror_device_id', null) as $device) @foreach(auth()->user()->devices->where('mirror_device_id', null) as $device)

View file

@ -1,6 +1,8 @@
<?php <?php
use App\Models\Plugin; use App\Models\Plugin;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -587,3 +589,93 @@ LIQUID
return str_contains($command, 'trmnl-liquid-cli'); return str_contains($command, 'trmnl-liquid-cli');
}); });
}); });
test('plugin render uses user timezone when set', function (): void {
$user = User::factory()->create([
'timezone' => 'America/New_York',
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}',
]);
$rendered = $plugin->render();
expect($rendered)->toContain('America/New_York');
});
test('plugin render falls back to app timezone when user timezone is not set', function (): void {
$user = User::factory()->create([
'timezone' => null,
]);
config(['app.timezone' => 'Europe/London']);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}',
]);
$rendered = $plugin->render();
expect($rendered)->toContain('Europe/London');
});
test('plugin render calculates correct UTC offset from user timezone', function (): void {
$user = User::factory()->create([
'timezone' => 'America/New_York', // UTC-5 (EST) or UTC-4 (EDT)
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
// America/New_York offset should be -18000 (EST) or -14400 (EDT) in seconds
$expectedOffset = (string) Carbon::now('America/New_York')->getOffset();
expect($rendered)->toContain($expectedOffset);
});
test('plugin render calculates correct UTC offset from app timezone when user timezone is null', function (): void {
$user = User::factory()->create([
'timezone' => null,
]);
config(['app.timezone' => 'Europe/London']);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
// Europe/London offset should be 0 (GMT) or 3600 (BST) in seconds
$expectedOffset = (string) Carbon::now('Europe/London')->getOffset();
expect($rendered)->toContain($expectedOffset);
});
test('plugin render includes utc_offset and time_zone_iana in trmnl.user context', function (): void {
$user = User::factory()->create([
'timezone' => 'America/Chicago', // UTC-6 (CST) or UTC-5 (CDT)
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}|{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
expect($rendered)
->toContain('America/Chicago')
->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
});