feat(#276): implement device pause functionality with custom duration (holiday mode)

This commit is contained in:
Benjamin Nussbaum
2026-08-04 20:24:05 +02:00
parent a0c1c82e82
commit 5d917ab85d
8 changed files with 424 additions and 19 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ class RunDeviceDisplayCycle
if ($device->isPauseActive()) {
return [
'image_path' => $this->defaultImagePath($device, 'sleep'),
'refresh_time_override' => (int) now()->diffInSeconds($device->pause_until),
'refresh_time_override' => min(Device::MAX_PAUSE_REFRESH_SECONDS, (int) now()->diffInSeconds($device->pause_until)),
];
}
+9
View File
@@ -18,6 +18,10 @@ class Device extends Model
{
use HasFactory;
public const MAX_PAUSE_DAYS = 30;
public const MAX_PAUSE_REFRESH_SECONDS = 86400; // 24 hours
protected $guarded = ['id'];
/**
@@ -320,4 +324,9 @@ class Device extends Model
{
return $this->pause_until && $this->pause_until->isFuture();
}
public function usesTouchBar(): bool
{
return $this->deviceModel?->name === 'v2';
}
}
+5
View File
@@ -70,6 +70,11 @@ class User extends Authenticatable implements PasskeyUser // implements MustVeri
->implode('');
}
public function preferredTimezone(): string
{
return $this->timezone ?? config('app.timezone');
}
public function devices(): HasMany
{
return $this->hasMany(Device::class);
+153
View File
@@ -28,6 +28,11 @@
"tailwindcss": "^4.0.7",
"vite": "^8.0.0"
},
"devDependencies": {
"prettier": "^3.9.6",
"prettier-plugin-blade": "^3.2.2",
"prettier-plugin-tailwindcss": "^0.8.1"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
@@ -441,6 +446,20 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@prettier/html-tags": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@prettier/html-tags/-/html-tags-2.2.0.tgz",
"integrity": "sha512-6qLjt8ncestmSfEOeVf/kX5VhkinUsppybvkwn1X7wu+BjQ/GBSZYu7cytxdCvi5BaljCGIAr4rmfL5zVqdgyQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@prettier/parse-srcset": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@prettier/parse-srcset/-/parse-srcset-3.1.0.tgz",
"integrity": "sha512-FIRv2rZotO9NP/r66taYqD1zICpPiBbrECqjKV/xqNotqDxF7fLzzUeK+1RSRx9Tenk0DajR/GcRmTJ2qtHaKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@puppeteer/browsers": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz",
@@ -1540,6 +1559,17 @@
"node": ">=8"
}
},
"node_modules/html-element-attributes": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/html-element-attributes/-/html-element-attributes-3.5.0.tgz",
"integrity": "sha512-rU2BFhp0kQla9sqPBI46C+zbP6PFOtD7z6XNAJ6as+cGecCDXLx0W3aIs6XdPLmBBG/Fy1meRi/n65Exofz4Qw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -1960,6 +1990,129 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-blade": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-blade/-/prettier-plugin-blade-3.2.2.tgz",
"integrity": "sha512-CPAlkLTAnvEZfvmRZ1CgdeePpfhxz/Bggei2lSAYcrsBFBhSZ04Ky0ttL4VB1u2q3iKh411vBi0T/Wx3GHWq/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@prettier/html-tags": "^2.2.0",
"@prettier/parse-srcset": "^3.1.0",
"html-element-attributes": "^3.5.0"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@prettier/plugin-php": "^0.24.0",
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.7.0 || ^0.8.0"
},
"peerDependenciesMeta": {
"@prettier/plugin-php": {
"optional": true
},
"prettier-plugin-tailwindcss": {
"optional": true
}
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz",
"integrity": "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.19"
},
"peerDependencies": {
"@ianvs/prettier-plugin-sort-imports": "*",
"@prettier/plugin-hermes": "*",
"@prettier/plugin-oxc": "*",
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
"prettier-plugin-jsdoc": "*",
"prettier-plugin-marko": "*",
"prettier-plugin-multiline-arrays": "*",
"prettier-plugin-organize-attributes": "*",
"prettier-plugin-organize-imports": "*",
"prettier-plugin-sort-imports": "*",
"prettier-plugin-svelte": "*"
},
"peerDependenciesMeta": {
"@ianvs/prettier-plugin-sort-imports": {
"optional": true
},
"@prettier/plugin-hermes": {
"optional": true
},
"@prettier/plugin-oxc": {
"optional": true
},
"@prettier/plugin-pug": {
"optional": true
},
"@shopify/prettier-plugin-liquid": {
"optional": true
},
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@zackad/prettier-plugin-twig": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
},
"prettier-plugin-css-order": {
"optional": true
},
"prettier-plugin-jsdoc": {
"optional": true
},
"prettier-plugin-marko": {
"optional": true
},
"prettier-plugin-multiline-arrays": {
"optional": true
},
"prettier-plugin-organize-attributes": {
"optional": true
},
"prettier-plugin-organize-imports": {
"optional": true
},
"prettier-plugin-sort-imports": {
"optional": true
},
"prettier-plugin-svelte": {
"optional": true
}
}
},
"node_modules/puppeteer": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.3.0.tgz",
+5
View File
@@ -34,5 +34,10 @@
"@rollup/rollup-linux-x64-gnu": "4.9.5",
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
"lightningcss-linux-x64-gnu": "^1.29.1"
},
"devDependencies": {
"prettier": "^3.9.6",
"prettier-plugin-blade": "^3.2.2",
"prettier-plugin-tailwindcss": "^0.8.1"
}
}
+120 -18
View File
@@ -2,6 +2,9 @@
use App\Models\Device;
use App\Models\DeviceModel;
use Carbon\Carbon;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Computed;
use Livewire\Component;
new class extends Component
@@ -28,7 +31,11 @@ new class extends Component
public $deviceModels;
public ?int $pause_duration;
public ?int $pause_duration = null;
public ?string $pause_until_date = null;
public ?string $pause_until_time = null;
protected $rules = [
'mac_address' => 'required',
@@ -40,7 +47,7 @@ new class extends Component
public function mount()
{
$this->devices = auth()->user()->devices;
$this->devices = auth()->user()->devices()->with('deviceModel')->get();
$this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) {
// Put TRMNL models at the top, then sort alphabetically within each group
$isTrmnl = str_starts_with($deviceModel->label, 'TRMNL');
@@ -51,12 +58,25 @@ new class extends Component
return view('livewire.devices.manage');
}
public function updatedDeviceModelId(): void
#[Computed]
public function timezone(): string
{
// Convert empty string to null for custom selection
if (empty($this->device_model_id)) {
return auth()->user()->preferredTimezone();
}
public function updated(string $property): void
{
if ($property === 'device_model_id' && empty($this->device_model_id)) {
$this->device_model_id = null;
}
if ($property === 'pause_duration' && $this->pause_duration !== null) {
$this->pause_until_date = $this->pause_until_time = null;
}
if (in_array($property, ['pause_until_date', 'pause_until_time'], true) && $this->{$property}) {
$this->pause_duration = null;
}
}
public function createDevice(): void
@@ -87,7 +107,7 @@ new class extends Component
$this->reset();
Flux::modal('create-device')->close();
$this->devices = auth()->user()->devices;
$this->devices = auth()->user()->devices()->with('deviceModel')->get();
Flux::toast(variant: 'success', text: 'Device created successfully.');
}
@@ -103,18 +123,47 @@ new class extends Component
// }
}
public function pauseDevice($deviceId): void
public function pauseDevice(int $deviceId): void
{
$this->validate([
'pause_duration' => 'required|integer',
]);
$device = auth()->user()->devices()->findOrFail($deviceId);
$pauseUntil = now()->addMinutes($this->pause_duration);
$device->update(['pause_until' => $pauseUntil]);
$this->reset('pause_duration');
$device->update(['pause_until' => $this->resolvePauseUntil()]);
$this->reset('pause_duration', 'pause_until_date', 'pause_until_time');
Flux::modal('pause-device-'.$deviceId)->close();
$this->devices = auth()->user()->devices;
Flux::toast(variant: 'success', text: 'Device paused until '.$pauseUntil->format('H:i'));
$this->devices = auth()->user()->devices()->with('deviceModel')->get();
$pauseUntil = $device->pause_until->timezone($this->timezone);
Flux::toast(variant: 'success', text: "Device paused until {$pauseUntil} {$this->timezone}");
}
public function unpauseDevice(int $deviceId): void
{
$device = auth()->user()->devices()->findOrFail($deviceId);
$device->update(['pause_until' => null]);
Flux::modal('unpause-device-'.$deviceId)->close();
$this->devices = auth()->user()->devices()->with('deviceModel')->get();
Flux::toast(variant: 'success', text: 'Pause cleared. Wake your device manually to resume.');
}
private function resolvePauseUntil(): Carbon
{
if (filled($this->pause_until_date) && filled($this->pause_until_time)) {
$now = now($this->timezone);
$pauseUntil = Carbon::parse("{$this->pause_until_date} {$this->pause_until_time}", $this->timezone);
if ($pauseUntil->lte($now) || $pauseUntil->gt($now->copy()->addDays(Device::MAX_PAUSE_DAYS))) {
throw ValidationException::withMessages([
'pause_until_date' => $pauseUntil->lte($now)
? 'The pause time must be in the future.'
: 'The pause time cannot be more than '.Device::MAX_PAUSE_DAYS.' days in the future.',
]);
}
return $pauseUntil->utc();
}
$this->validate(['pause_duration' => 'required|integer']);
return now()->addMinutes((int) $this->pause_duration);
}
}
@@ -265,9 +314,11 @@ new class extends Component
<flux:button href="{{ route('devices.configure', $device) }}" wire:navigate icon="eye" iconVariant="outline">
</flux:button>
@if($device->isPauseActive())
<flux:tooltip content="Device paused until: {{ $device->pause_until?->format('H:i') }}">
<flux:button icon="pause-circle"/>
</flux:tooltip>
<flux:modal.trigger name="unpause-device-{{ $device->id }}">
<flux:tooltip content="Device paused until: {{ $device->pause_until->diffForHumans() }}">
<flux:button icon="pause-circle"/>
</flux:tooltip>
</flux:modal.trigger>
@else
<flux:modal.trigger name="pause-device-{{ $device->id }}">
<flux:button icon="pause-circle" iconVariant="outline">
@@ -314,6 +365,26 @@ new class extends Component
<flux:radio value="480" label="480 min"/>
</flux:radio.group>
</div>
<flux:separator text="or" class="my-4"/>
<div class="mb-4 grid grid-cols-2 gap-4">
<flux:input
type="date"
label="Date"
wire:model.live="pause_until_date"
min="{{ now($this->timezone)->toDateString() }}"
max="{{ now($this->timezone)->addDays(\App\Models\Device::MAX_PAUSE_DAYS)->toDateString() }}"
/>
<flux:input
type="time"
label="Time"
wire:model.live="pause_until_time"
/>
</div>
<flux:text class="text-sm text-zinc-500 mb-4">Timezone: {{ $this->timezone }}.</flux:text>
<flux:error name="pause_until_date"/>
<div class="flex">
<flux:spacer/>
<flux:modal.close>
@@ -324,5 +395,36 @@ new class extends Component
</form>
</div>
</flux:modal>
<flux:modal name="unpause-device-{{ $device->id }}">
<div class="space-y-6">
<div>
<flux:heading size="lg">Pause Active</flux:heading>
</div>
<flux:callout variant="info" icon="pause-circle">
<flux:callout.heading>Paused until {{ $device->pause_until?->timezone($this->timezone) }} {{ $this->timezone }}</flux:callout.heading>
<flux:callout.text>
@if($device->usesTouchBar())
To exit pause early, click "End pause" and press the touch bar in the middle of your device.
@else
To exit pause early, click "End pause" and press the physical screen button on your device.
@endif
</flux:callout.text>
<x-slot name="actions">
<flux:button wire:click="unpauseDevice({{ $device->id }})" variant="primary">
End pause
</flux:button>
</x-slot>
</flux:callout>
<div class="flex">
<flux:spacer/>
<flux:modal.close>
<flux:button variant="ghost">Close</flux:button>
</flux:modal.close>
</div>
</div>
</flux:modal>
@endforeach
</div>
+23
View File
@@ -1601,6 +1601,29 @@ test('device returns sleep.png and correct refresh time when paused', function (
expect($json['refresh_rate'])->toBeLessThanOrEqual(3600); // ~60 min
});
test('device paused for long period returns refresh rate capped at 24 hours', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'pause_until' => now()->addDays(7),
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
$json = $response->json();
expect($json['filename'])->toMatch('/^[a-f0-9-]+\.png$/');
expect($json['image_url'])->toContain('images/generated/');
expect($json['refresh_rate'])->toBeLessThanOrEqual(86400);
});
test('screens endpoint accepts nullable file_name', function (): void {
Queue::fake();
+108
View File
@@ -1,7 +1,9 @@
<?php
use App\Models\Device;
use App\Models\DeviceModel;
use App\Models\User;
use Carbon\Carbon;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@@ -103,3 +105,109 @@ test('user cannot toggle proxy cloud for other users devices', function (): void
$response->assertStatus(403);
expect($device->fresh()->proxy_cloud)->toBeFalse();
});
test('user can pause device with preset duration', function (): void {
$user = User::factory()->create();
$device = Device::factory()->create(['user_id' => $user->id]);
$this->actingAs($user);
Carbon::setTestNow('2026-08-04 12:00:00');
Livewire::test('devices.manage')
->set('pause_duration', 60)
->call('pauseDevice', $device->id)
->assertHasNoErrors();
expect($device->fresh()->pause_until?->equalTo(now()->addMinutes(60)))->toBeTrue();
});
test('user can pause device with custom date and time in user timezone', function (): void {
$user = User::factory()->create(['timezone' => 'Europe/Berlin']);
$device = Device::factory()->create(['user_id' => $user->id]);
$this->actingAs($user);
Carbon::setTestNow(Carbon::parse('2026-08-04 10:00:00', 'UTC'));
Livewire::test('devices.manage')
->set('pause_until_date', '2026-08-10')
->set('pause_until_time', '14:30')
->call('pauseDevice', $device->id)
->assertHasNoErrors();
expect($device->fresh()->pause_until?->utc()->format('Y-m-d H:i'))->toBe('2026-08-10 12:30');
});
test('custom pause rejects datetime more than 30 days in the future', function (): void {
$user = User::factory()->create(['timezone' => 'UTC']);
$device = Device::factory()->create(['user_id' => $user->id]);
$this->actingAs($user);
Carbon::setTestNow('2026-08-04 12:00:00');
Livewire::test('devices.manage')
->set('pause_until_date', '2026-09-10')
->set('pause_until_time', '14:30')
->call('pauseDevice', $device->id)
->assertHasErrors(['pause_until_date']);
expect($device->fresh()->pause_until)->toBeNull();
});
test('custom pause rejects datetime in the past', function (): void {
$user = User::factory()->create(['timezone' => 'UTC']);
$device = Device::factory()->create(['user_id' => $user->id]);
$this->actingAs($user);
Carbon::setTestNow('2026-08-04 12:00:00');
Livewire::test('devices.manage')
->set('pause_until_date', '2026-08-04')
->set('pause_until_time', '08:00')
->call('pauseDevice', $device->id)
->assertHasErrors(['pause_until_date']);
expect($device->fresh()->pause_until)->toBeNull();
});
test('user can end pause early', function (): void {
$user = User::factory()->create();
$device = Device::factory()->create([
'user_id' => $user->id,
'pause_until' => now()->addHour(),
]);
$this->actingAs($user);
Livewire::test('devices.manage')
->call('unpauseDevice', $device->id)
->assertHasNoErrors();
expect($device->fresh()->pause_until)->toBeNull();
});
test('unpause modal shows touch bar instructions for v2 devices', function (): void {
$user = User::factory()->create();
$deviceModel = DeviceModel::query()->where('name', 'v2')->firstOrFail();
$device = Device::factory()->create([
'user_id' => $user->id,
'device_model_id' => $deviceModel->id,
'pause_until' => now()->addHour(),
]);
$this->actingAs($user);
Livewire::test('devices.manage')
->assertSee('touch bar in the middle')
->assertDontSee('physical screen button');
});
test('unpause modal shows screen button instructions for non-v2 devices', function (): void {
$user = User::factory()->create();
$device = Device::factory()->create([
'user_id' => $user->id,
'pause_until' => now()->addHour(),
]);
$this->actingAs($user);
Livewire::test('devices.manage')
->assertSee('physical screen button')
->assertDontSee('touch bar in the middle');
});