Fix R15: include room-less devices in home status

HomeStatus aggregated from Room, so a device with room_id = null (a supported
state, e.g. after picking "no room") vanished from dashboard totals, KPIs and
warnings. Aggregate from all devices instead; the dashboard groups them by room
with a trailing "Ohne Raum" group so nothing disappears. Added a feature test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-17 21:58:35 +02:00
parent dc27b1686c
commit 1520b95f8f
7 changed files with 48 additions and 19 deletions

View File

@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Models\Room;
use App\Services\HomeStatus;
use Livewire\Attributes\Layout;
use Livewire\Component;
@ -11,12 +12,26 @@ class Dashboard extends Component
{
public function render(HomeStatus $home)
{
$rooms = $home->load();
$devices = $home->devices();
// Group by room in room order; room-less devices go into a trailing "no room" group
// so they never disappear from the overview.
$groups = [];
foreach (Room::orderBy('sort')->orderBy('name')->get() as $room) {
$roomDevices = $devices->where('room_id', $room->id)->values();
if ($roomDevices->isNotEmpty()) {
$groups[] = ['name' => $room->name, 'devices' => $roomDevices];
}
}
$unassigned = $devices->whereNull('room_id')->values();
if ($unassigned->isNotEmpty()) {
$groups[] = ['name' => __('dashboard.no_room'), 'devices' => $unassigned];
}
return view('livewire.dashboard', [
'rooms' => $rooms,
'summary' => $home->summary($rooms),
'warningCount' => count($home->warnings($rooms)),
'groups' => $groups,
'summary' => $home->summary($devices),
'warningCount' => count($home->warnings($devices)),
]);
}
}

View File

@ -17,7 +17,7 @@ class Warnings extends ModalComponent
$home = app(HomeStatus::class);
return view('livewire.modals.warnings', [
'warnings' => $home->warnings($home->load()),
'warnings' => $home->warnings($home->devices()),
]);
}
}

View File

@ -2,12 +2,12 @@
namespace App\Services;
use App\Models\Room;
use App\Models\Device;
use Illuminate\Support\Collection;
/**
* Aggregates the current home state (rooms/devices/entities) into a summary and a
* warning list. Shared by the Dashboard (grid + warning count) and the Warnings modal.
* Aggregates the current home state (all devices/entities, incl. those with no room)
* into a summary and a warning list. Shared by the Dashboard and the Warnings modal.
*/
class HomeStatus
{
@ -15,17 +15,17 @@ class HomeStatus
public function __construct(private readonly SystemHealth $health) {}
public function load(): Collection
/** Every device (including room-less ones), eager-loaded for aggregation + display. */
public function devices(): Collection
{
return Room::query()
->with(['devices' => fn ($q) => $q->orderBy('name'), 'devices.entities.state'])
->orderBy('sort')->orderBy('name')
return Device::query()
->with(['entities.state', 'room'])
->orderBy('name')
->get();
}
public function summary(Collection $rooms): array
public function summary(Collection $devices): array
{
$devices = $rooms->flatMap->devices;
$entities = $devices->flatMap->entities;
return [
@ -38,9 +38,8 @@ class HomeStatus
}
/** @return array<int, array{level:string,icon:string,title:string,meta:?string,link:?string}> */
public function warnings(Collection $rooms): array
public function warnings(Collection $devices): array
{
$devices = $rooms->flatMap->devices;
$entities = $devices->flatMap->entities;
$warnings = [];

View File

@ -14,6 +14,7 @@ return [
'kpi_low_battery' => 'Niedriger Akku',
'rooms_title' => 'Räume',
'no_room' => 'Ohne Raum',
'warn_host' => 'Systemdienst gestört',
'warn_host_meta' => 'Details im Host-Bereich',

View File

@ -14,6 +14,7 @@ return [
'kpi_low_battery' => 'Low battery',
'rooms_title' => 'Rooms',
'no_room' => 'No room',
'warn_host' => 'System service degraded',
'warn_host_meta' => 'Details in the Host area',

View File

@ -30,10 +30,10 @@
<div>
<h2 class="text-[13px] font-bold text-ink-2 uppercase tracking-[0.08em] mb-3">{{ __('dashboard.rooms_title') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
@foreach ($rooms as $room)
<x-panel :title="$room->name" class="reveal">
@foreach ($groups as $group)
<x-panel :title="$group['name']" class="reveal">
<div class="flex flex-col divide-y divide-line-soft -my-1">
@foreach ($room->devices as $device)
@foreach ($group['devices'] as $device)
<a href="{{ route('devices.show', $device) }}" wire:navigate
class="flex flex-col gap-2 py-3 first:pt-1 last:pb-1 -mx-4 px-4 hover:bg-raised/50 transition-colors">
<div class="flex items-center gap-2.5">

View File

@ -58,6 +58,19 @@ class HomeTest extends TestCase
->assertSee('Türsensor');
}
public function test_room_less_device_appears_in_home_status(): void
{
$device = Device::create(['name' => 'Heimloser Sensor', 'status' => 'active', 'last_seen_at' => now(), 'room_id' => null]);
$battery = Entity::create(['device_id' => $device->id, 'type' => 'battery', 'key' => 'battery:0', 'name' => 'Batterie']);
DeviceState::create(['entity_id' => $battery->id, 'state' => ['percent' => 5]]);
$this->actingAs(User::factory()->create())
->get('/dashboard')
->assertOk()
->assertSee('Heimloser Sensor')
->assertSee(__('dashboard.no_room'));
}
public function test_device_room_update_rejects_invalid_room(): void
{
$device = Device::create(['name' => 'Lampe', 'status' => 'active', 'last_seen_at' => now()]);