-
Room Details - {{ roomId }}
-
Detailed room information coming soon.
-
← Back to Dashboard
+
+
← Back to Dashboard
+
+ @if (room(); as r) {
+
+
+ @if (latestReading(); as reading) {
+
+ {{ getCO2Level(reading.co2).label }}
+ {{ reading.co2 }} ppm CO₂
+
+
+
+
+
🌫️
+
{{ reading.co2 }}
+
ppm CO₂
+
+
+
🌡️
+
{{ reading.temperature }}
+
°C
+
+
+
💧
+
{{ reading.humidity }}
+
% Humidity
+
+
+
🪟
+
{{ reading.windowState }}
+
Windows
+
+
+
+
+ Last updated: {{ reading.timestamp | date: 'HH:mm:ss, dd MMM yyyy' : 'UTC' }} UTC
+
+ } @else {
+
No sensor installed in this room.
+ }
+
+ @if (history().length > 0) {
+
+ Last 24h readings
+
+
+
+
+ | Time (UTC) |
+ CO₂ (ppm) |
+ Temp (°C) |
+ Humidity (%) |
+ Windows |
+
+
+
+ @for (entry of pagedHistory(); track entry.timestamp) {
+
+ | {{ entry.timestamp | date: 'HH:mm' : 'UTC' }} |
+
+
+ {{ entry.co2 }}
+
+ |
+ {{ entry.temperature }} |
+ {{ entry.humidity }} |
+ {{ entry.windowState }} |
+
+ }
+
+
+
+
+
+
+ }
+ } @else {
+
Room not found.
+ }
diff --git a/ui/src/app/components/room-details-panel/room-details-panel.component.scss b/ui/src/app/components/room-details-panel/room-details-panel.component.scss
index c9168b5..77c8ea9 100644
--- a/ui/src/app/components/room-details-panel/room-details-panel.component.scss
+++ b/ui/src/app/components/room-details-panel/room-details-panel.component.scss
@@ -1,16 +1,206 @@
-.room-details-container {
+.details-container {
+ max-width: 900px;
+ margin: 0 auto;
padding: 24px;
+
+ @media (max-width: 600px) {
+ padding: 16px;
+ }
}
-h1 {
- margin-bottom: 16px;
-}
-
-a {
+.back-link {
+ display: inline-block;
color: #00bcd4;
text-decoration: none;
+ font-size: 14px;
+ margin-bottom: 20px;
&:hover {
text-decoration: underline;
}
}
+
+.room-header {
+ border-left: 5px solid #00bcd4;
+ padding-left: 16px;
+ margin-bottom: 20px;
+
+ h1 {
+ margin: 0;
+ font-size: 26px;
+ color: #263238;
+ }
+}
+
+.status-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-radius: 6px;
+ padding: 12px 20px;
+ margin-bottom: 20px;
+ color: rgba(0, 0, 0, 0.75);
+ font-weight: 600;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.status-label {
+ font-size: 16px;
+}
+
+.status-ppm {
+ font-size: 20px;
+
+ @media (max-width: 400px) {
+ font-size: 16px;
+ }
+}
+
+.metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 16px;
+ margin-bottom: 12px;
+
+ @media (max-width: 700px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (max-width: 360px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.metric-card {
+ background: #f5f5f5;
+ border-radius: 8px;
+ padding: 20px 16px;
+ text-align: center;
+ border: 2px solid transparent;
+
+ &.open {
+ border-color: #4caf50;
+ }
+
+ .metric-icon {
+ font-size: 28px;
+ margin-bottom: 8px;
+ }
+
+ .metric-value {
+ font-size: 24px;
+ font-weight: 700;
+ color: #263238;
+ text-transform: capitalize;
+ }
+
+ .metric-unit {
+ font-size: 12px;
+ color: #78909c;
+ margin-top: 4px;
+ }
+}
+
+.timestamp {
+ font-size: 12px;
+ color: #90a4ae;
+ text-align: right;
+ margin-bottom: 32px;
+}
+
+.no-sensor,
+.not-found {
+ padding: 40px;
+ text-align: center;
+ color: #90a4ae;
+ font-style: italic;
+ background: #fafafa;
+ border-radius: 8px;
+ border: 1px dashed #cfd8dc;
+}
+
+.history-section {
+ h2 {
+ font-size: 16px;
+ color: #455a64;
+ margin-bottom: 12px;
+ }
+}
+
+.history-table-wrapper {
+ overflow-x: auto;
+ border-radius: 8px;
+ border: 1px solid #eceff1;
+}
+
+.history-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+
+ th {
+ background: #eceff1;
+ color: #546e7a;
+ font-weight: 600;
+ padding: 10px 14px;
+ text-align: left;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 8px 14px;
+ border-top: 1px solid #f5f5f5;
+ color: #37474f;
+ text-transform: capitalize;
+ }
+
+ tr:hover td {
+ background: #f9fafb;
+ }
+}
+
+.co2-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-weight: 600;
+ color: rgba(0, 0, 0, 0.7);
+ font-size: 12px;
+}
+
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ padding: 16px 0 24px;
+}
+
+.page-btn {
+ background: #00bcd4;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 8px 18px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+
+ &:hover:not(:disabled) {
+ background: #0097a7;
+ }
+
+ &:disabled {
+ background: #eceff1;
+ color: #b0bec5;
+ cursor: default;
+ }
+}
+
+.page-info {
+ font-size: 13px;
+ color: #546e7a;
+ white-space: nowrap;
+}
diff --git a/ui/src/app/components/room-details-panel/room-details-panel.component.spec.ts b/ui/src/app/components/room-details-panel/room-details-panel.component.spec.ts
new file mode 100644
index 0000000..655eaa7
--- /dev/null
+++ b/ui/src/app/components/room-details-panel/room-details-panel.component.spec.ts
@@ -0,0 +1,174 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { convertToParamMap, provideRouter } from '@angular/router';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+import { RoomDetailsPanelComponent } from './room-details-panel.component';
+import { RoomService } from '../../services/room.service';
+import { SensorService } from '../../services/sensor.service';
+import { ROOM_LAYOUTS } from '../../config/rooms-layout.config';
+import { SensorReading } from '../../models/sensor-reading.model';
+
+const MOCK_ROOM = ROOM_LAYOUTS.find(r => r.id === 'A1')!;
+
+const makeReading = (co2: number, i = 0): SensorReading => ({
+ roomId: 'A1',
+ roomName: 'A1 - Auditorium',
+ co2,
+ temperature: 21.5,
+ humidity: 45,
+ windowState: 'closed',
+ timestamp: new Date(Date.now() - i * 3600000),
+});
+
+const MOCK_HISTORY: SensorReading[] = Array.from({ length: 24 }, (_, i) =>
+ makeReading(700 + i * 10, i)
+);
+
+describe('RoomDetailsPanelComponent', () => {
+ let fixture: ComponentFixture
;
+ let component: RoomDetailsPanelComponent;
+ let roomServiceSpy: jasmine.SpyObj;
+ let sensorServiceSpy: jasmine.SpyObj;
+
+ beforeEach(async () => {
+ roomServiceSpy = jasmine.createSpyObj('RoomService', ['getRoomById']);
+ sensorServiceSpy = jasmine.createSpyObj('SensorService', [
+ 'getLatestReadingForRoom',
+ 'getHistoryForRoom',
+ ]);
+
+ roomServiceSpy.getRoomById.and.returnValue(of(MOCK_ROOM));
+ sensorServiceSpy.getLatestReadingForRoom.and.returnValue(of(MOCK_HISTORY[0]));
+ sensorServiceSpy.getHistoryForRoom.and.returnValue(of(MOCK_HISTORY));
+
+ await TestBed.configureTestingModule({
+ imports: [RoomDetailsPanelComponent],
+ providers: [
+ provideRouter([]),
+ {
+ provide: ActivatedRoute,
+ useValue: { paramMap: of(convertToParamMap({ id: 'A1' })) },
+ },
+ { provide: RoomService, useValue: roomServiceSpy },
+ { provide: SensorService, useValue: sensorServiceSpy },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(RoomDetailsPanelComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should call services with room id from route', () => {
+ expect(roomServiceSpy.getRoomById).toHaveBeenCalledWith('A1');
+ expect(sensorServiceSpy.getLatestReadingForRoom).toHaveBeenCalledWith('A1');
+ expect(sensorServiceSpy.getHistoryForRoom).toHaveBeenCalledWith('A1');
+ });
+
+ it('should populate room signal', () => {
+ expect(component.room()).toEqual(MOCK_ROOM);
+ });
+
+ it('should populate latestReading signal', () => {
+ expect(component.latestReading()).toEqual(MOCK_HISTORY[0]);
+ });
+
+ it('should populate history signal with 24 entries', () => {
+ expect(component.history().length).toBe(24);
+ });
+
+ it('should display room name', () => {
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.textContent).toContain(MOCK_ROOM.name);
+ });
+
+ describe('pagination', () => {
+ it('should start on page 0', () => {
+ expect(component.currentPage()).toBe(0);
+ });
+
+ it('should compute totalPages correctly for 24 entries with pageSize 8', () => {
+ expect(component.totalPages()).toBe(3);
+ });
+
+ it('pagedHistory should return first 8 entries on page 0', () => {
+ expect(component.pagedHistory().length).toBe(8);
+ expect(component.pagedHistory()[0]).toEqual(MOCK_HISTORY[0]);
+ });
+
+ it('nextPage() should increment currentPage', () => {
+ component.nextPage();
+ expect(component.currentPage()).toBe(1);
+ });
+
+ it('nextPage() should not exceed last page', () => {
+ component.currentPage.set(2);
+ component.nextPage();
+ expect(component.currentPage()).toBe(2);
+ });
+
+ it('prevPage() should decrement currentPage', () => {
+ component.currentPage.set(1);
+ component.prevPage();
+ expect(component.currentPage()).toBe(0);
+ });
+
+ it('prevPage() should not go below 0', () => {
+ component.prevPage();
+ expect(component.currentPage()).toBe(0);
+ });
+
+ it('pagedHistory on page 1 should return entries 8–15', () => {
+ component.currentPage.set(1);
+ const page = component.pagedHistory();
+ expect(page.length).toBe(8);
+ expect(page[0]).toEqual(MOCK_HISTORY[8]);
+ });
+
+ it('pagedHistory on last page returns remaining entries', () => {
+ component.currentPage.set(2);
+ const page = component.pagedHistory();
+ expect(page.length).toBe(8);
+ expect(page[0]).toEqual(MOCK_HISTORY[16]);
+ });
+
+ it('should reset to page 0 when history is updated', () => {
+ component.currentPage.set(2);
+ component.history.set(MOCK_HISTORY);
+ component.currentPage.set(0);
+ expect(component.currentPage()).toBe(0);
+ });
+ });
+
+ describe('template', () => {
+ it('should render the latest CO2 value', () => {
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.textContent).toContain(MOCK_HISTORY[0].co2.toString());
+ });
+
+ it('should render pagination controls', () => {
+ const compiled = fixture.nativeElement as HTMLElement;
+ const buttons = compiled.querySelectorAll('.page-btn');
+ expect(buttons.length).toBe(2);
+ });
+
+ it('prev button should be disabled on page 0', () => {
+ fixture.detectChanges();
+ const compiled = fixture.nativeElement as HTMLElement;
+ const prevBtn = compiled.querySelector('.page-btn:first-of-type');
+ expect(prevBtn?.disabled).toBeTrue();
+ });
+
+ it('next button should be disabled on last page', () => {
+ component.currentPage.set(2);
+ fixture.detectChanges();
+ const compiled = fixture.nativeElement as HTMLElement;
+ const nextBtn = compiled.querySelector('.page-btn:last-of-type');
+ expect(nextBtn?.disabled).toBeTrue();
+ });
+ });
+});
diff --git a/ui/src/app/components/room-details-panel/room-details-panel.component.ts b/ui/src/app/components/room-details-panel/room-details-panel.component.ts
index c4834d9..128ce86 100644
--- a/ui/src/app/components/room-details-panel/room-details-panel.component.ts
+++ b/ui/src/app/components/room-details-panel/room-details-panel.component.ts
@@ -1,8 +1,22 @@
-import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ OnInit,
+ computed,
+ inject,
+ signal,
+} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
-import { map } from 'rxjs/operators';
+import { map, switchMap } from 'rxjs/operators';
+import { RoomLayout } from '../../config/rooms-layout.config';
+import { getCO2Color, getCO2Level } from '../../config/co2-levels.config';
+import { SensorReading } from '../../models/sensor-reading.model';
+import { RoomService } from '../../services/room.service';
+import { SensorService } from '../../services/sensor.service';
+
+const PAGE_SIZE = 8;
@Component({
selector: 'app-room-details-panel',
@@ -12,7 +26,51 @@ import { map } from 'rxjs/operators';
styleUrl: './room-details-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class RoomDetailsPanelComponent {
+export class RoomDetailsPanelComponent implements OnInit {
private route = inject(ActivatedRoute);
+ private roomService = inject(RoomService);
+ private sensorService = inject(SensorService);
+
roomId = toSignal(this.route.paramMap.pipe(map(params => params.get('id'))));
+
+ room = signal(undefined);
+ latestReading = signal(undefined);
+ history = signal([]);
+
+ currentPage = signal(0);
+ readonly pageSize = PAGE_SIZE;
+
+ totalPages = computed(() => Math.ceil(this.history().length / PAGE_SIZE));
+ pagedHistory = computed(() => {
+ const start = this.currentPage() * PAGE_SIZE;
+ return this.history().slice(start, start + PAGE_SIZE);
+ });
+
+ getCO2Color = getCO2Color;
+ getCO2Level = getCO2Level;
+
+ ngOnInit(): void {
+ const id$ = this.route.paramMap.pipe(map(params => params.get('id') ?? ''));
+
+ id$
+ .pipe(switchMap(id => this.roomService.getRoomById(id)))
+ .subscribe(room => this.room.set(room));
+
+ id$
+ .pipe(switchMap(id => this.sensorService.getLatestReadingForRoom(id)))
+ .subscribe(reading => this.latestReading.set(reading));
+
+ id$.pipe(switchMap(id => this.sensorService.getHistoryForRoom(id))).subscribe(history => {
+ this.history.set(history);
+ this.currentPage.set(0);
+ });
+ }
+
+ prevPage(): void {
+ if (this.currentPage() > 0) this.currentPage.update(p => p - 1);
+ }
+
+ nextPage(): void {
+ if (this.currentPage() < this.totalPages() - 1) this.currentPage.update(p => p + 1);
+ }
}
diff --git a/ui/src/app/components/room-map/room-map.component.html b/ui/src/app/components/room-map/room-map.component.html
index 2cf9483..5b40220 100644
--- a/ui/src/app/components/room-map/room-map.component.html
+++ b/ui/src/app/components/room-map/room-map.component.html
@@ -1,4 +1,92 @@
-
-
Room Map
-
Interactive 2D SVG map will be displayed here.
+
+
+
Building Floor Plan
+
+
+
+
+
+ @if (tooltip(); as t) {
+
+ }
+
+
+
+
diff --git a/ui/src/app/components/room-map/room-map.component.scss b/ui/src/app/components/room-map/room-map.component.scss
index 5418842..22a772c 100644
--- a/ui/src/app/components/room-map/room-map.component.scss
+++ b/ui/src/app/components/room-map/room-map.component.scss
@@ -1,9 +1,164 @@
-.room-map-container {
+.dashboard-layout {
+ display: flex;
+ gap: 24px;
padding: 24px;
- text-align: center;
+ align-items: flex-start;
+ min-height: 0;
+
+ @media (max-width: 900px) {
+ flex-direction: column;
+ padding: 16px;
+ }
}
-h1 {
- color: #00bcd4;
- margin-bottom: 16px;
+.map-wrapper {
+ flex: 1;
+ min-width: 0;
+}
+
+.map-title {
+ color: #00bcd4;
+ margin: 0 0 16px;
+ font-size: 20px;
+}
+
+.svg-container {
+ position: relative;
+ background: #fafafa;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ -webkit-overflow-scrolling: touch;
+
+ svg {
+ display: block;
+ min-width: 600px;
+ width: 100%;
+ height: auto;
+ }
+
+ @media (max-width: 900px) {
+ svg {
+ min-width: 720px;
+ }
+ }
+}
+
+/* SVG styles */
+.section-label {
+ font-size: 22px;
+ font-weight: 700;
+ fill: #455a64;
+ text-anchor: middle;
+}
+
+.corridor {
+ fill: #eceff1;
+}
+
+.corridor-label {
+ font-size: 14px;
+ fill: #90a4ae;
+ text-anchor: middle;
+ writing-mode: vertical-rl;
+ dominant-baseline: middle;
+}
+
+.room-group {
+ cursor: pointer;
+
+ .room-rect {
+ stroke: #fff;
+ stroke-width: 2;
+ transition: filter 0.15s ease;
+ }
+
+ &:hover .room-rect {
+ filter: brightness(0.88);
+ stroke: #37474f;
+ stroke-width: 2.5;
+ }
+
+ &.no-sensor {
+ cursor: default;
+ }
+}
+
+.room-id {
+ font-size: 18px;
+ font-weight: 700;
+ fill: rgba(0, 0, 0, 0.75);
+ text-anchor: middle;
+ pointer-events: none;
+}
+
+.room-co2 {
+ font-size: 13px;
+ fill: rgba(0, 0, 0, 0.6);
+ text-anchor: middle;
+ pointer-events: none;
+}
+
+.room-window {
+ font-size: 11px;
+ fill: rgba(0, 0, 0, 0.55);
+ text-anchor: middle;
+ pointer-events: none;
+}
+
+/* Tooltip */
+.tooltip {
+ position: absolute;
+ background: #263238;
+ color: #fff;
+ border-radius: 6px;
+ padding: 10px 14px;
+ pointer-events: none;
+ white-space: nowrap;
+ z-index: 10;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
+ min-width: 160px;
+}
+
+.tooltip-title {
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 6px;
+ color: #80cbc4;
+}
+
+.tooltip-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ font-size: 12px;
+ line-height: 1.8;
+ color: #cfd8dc;
+
+ strong {
+ color: #fff;
+ }
+}
+
+.tooltip-no-sensor {
+ font-size: 12px;
+ color: #90a4ae;
+ font-style: italic;
+}
+
+/* Sidebar */
+.sidebar {
+ width: 220px;
+ flex-shrink: 0;
+ position: sticky;
+ top: 24px;
+ max-height: calc(100vh - 48px);
+ overflow-y: auto;
+
+ @media (max-width: 900px) {
+ width: 100%;
+ position: static;
+ max-height: none;
+ }
}
diff --git a/ui/src/app/components/room-map/room-map.component.spec.ts b/ui/src/app/components/room-map/room-map.component.spec.ts
new file mode 100644
index 0000000..41ca260
--- /dev/null
+++ b/ui/src/app/components/room-map/room-map.component.spec.ts
@@ -0,0 +1,107 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { provideRouter, Router } from '@angular/router';
+import { of } from 'rxjs';
+import { RoomMapComponent } from './room-map.component';
+import { RoomService } from '../../services/room.service';
+import { SensorService } from '../../services/sensor.service';
+import { ROOM_LAYOUTS } from '../../config/rooms-layout.config';
+import { SensorReading } from '../../models/sensor-reading.model';
+import { getCO2Color } from '../../config/co2-levels.config';
+
+const MOCK_READINGS: SensorReading[] = ROOM_LAYOUTS.filter(r => r.hasSensor).map(room => ({
+ roomId: room.id,
+ roomName: room.name,
+ co2: 700,
+ temperature: 21.5,
+ humidity: 45,
+ windowState: 'closed' as const,
+ timestamp: new Date('2026-01-01T10:00:00Z'),
+}));
+
+describe('RoomMapComponent', () => {
+ let fixture: ComponentFixture
;
+ let component: RoomMapComponent;
+ let roomServiceSpy: jasmine.SpyObj;
+ let sensorServiceSpy: jasmine.SpyObj;
+ let router: Router;
+
+ beforeEach(async () => {
+ roomServiceSpy = jasmine.createSpyObj('RoomService', ['getRooms']);
+ sensorServiceSpy = jasmine.createSpyObj('SensorService', ['getLatestReadings']);
+
+ roomServiceSpy.getRooms.and.returnValue(of(ROOM_LAYOUTS));
+ sensorServiceSpy.getLatestReadings.and.returnValue(of(MOCK_READINGS));
+
+ await TestBed.configureTestingModule({
+ imports: [RoomMapComponent],
+ providers: [
+ provideRouter([]),
+ { provide: RoomService, useValue: roomServiceSpy },
+ { provide: SensorService, useValue: sensorServiceSpy },
+ ],
+ }).compileComponents();
+
+ router = TestBed.inject(Router);
+ fixture = TestBed.createComponent(RoomMapComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should call getRooms() and getLatestReadings() on init', () => {
+ expect(roomServiceSpy.getRooms).toHaveBeenCalledOnceWith();
+ expect(sensorServiceSpy.getLatestReadings).toHaveBeenCalledOnceWith();
+ });
+
+ it('should populate rooms signal after init', () => {
+ expect(component.rooms().length).toBe(ROOM_LAYOUTS.length);
+ });
+
+ it('should assign CO2 color to rooms with sensor data', () => {
+ const roomWithSensor = component.rooms().find(r => r.reading !== undefined);
+ expect(roomWithSensor).toBeDefined();
+ expect(roomWithSensor!.color).toBe(getCO2Color(700));
+ });
+
+ it('should assign gray color (#e0e0e0) to rooms without sensor', () => {
+ const roomWithoutSensor = component.rooms().find(r => !r.layout.hasSensor);
+ if (roomWithoutSensor) {
+ expect(roomWithoutSensor.color).toBe(component.NO_SENSOR_COLOR);
+ }
+ });
+
+ it('should navigate to /room/:id when navigateToRoom() is called', () => {
+ const spy = spyOn(router, 'navigate');
+ component.navigateToRoom('A1');
+ expect(spy).toHaveBeenCalledWith(['/room', 'A1']);
+ });
+
+ it('should set tooltip on showTooltip()', () => {
+ const mockRoom = component.rooms()[0];
+ const mockSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ mockSvg.getBoundingClientRect = () => ({ left: 0, top: 0 }) as DOMRect;
+ document.body.appendChild(mockSvg);
+
+ const mockRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ mockSvg.appendChild(mockRect);
+
+ const event = new MouseEvent('mouseenter', { clientX: 100, clientY: 150 });
+ Object.defineProperty(event, 'target', { value: mockRect });
+
+ component.showTooltip(event, mockRoom);
+ expect(component.tooltip()).not.toBeNull();
+ expect(component.tooltip()!.room).toBe(mockRoom);
+
+ document.body.removeChild(mockSvg);
+ });
+
+ it('should clear tooltip on hideTooltip()', () => {
+ const mockRoom = component.rooms()[0];
+ component.tooltip.set({ room: mockRoom, x: 10, y: 10 });
+ component.hideTooltip();
+ expect(component.tooltip()).toBeNull();
+ });
+});
diff --git a/ui/src/app/components/room-map/room-map.component.ts b/ui/src/app/components/room-map/room-map.component.ts
index 5279413..9e2fa03 100644
--- a/ui/src/app/components/room-map/room-map.component.ts
+++ b/ui/src/app/components/room-map/room-map.component.ts
@@ -1,12 +1,71 @@
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { Router } from '@angular/router';
+import { combineLatest } from 'rxjs';
+import { RoomLayout } from '../../config/rooms-layout.config';
+import { getCO2Color } from '../../config/co2-levels.config';
+import { SensorReading } from '../../models/sensor-reading.model';
+import { RoomService } from '../../services/room.service';
+import { SensorService } from '../../services/sensor.service';
+import { LegendComponent } from '../legend/legend.component';
+
+export interface RoomMapEntry {
+ layout: RoomLayout;
+ reading?: SensorReading;
+ color: string;
+}
@Component({
selector: 'app-room-map',
standalone: true,
- imports: [CommonModule],
+ imports: [CommonModule, LegendComponent],
templateUrl: './room-map.component.html',
styleUrl: './room-map.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class RoomMapComponent {}
+export class RoomMapComponent implements OnInit {
+ private router = inject(Router);
+ private roomService = inject(RoomService);
+ private sensorService = inject(SensorService);
+
+ rooms = signal([]);
+ tooltip = signal<{ room: RoomMapEntry; x: number; y: number } | null>(null);
+
+ readonly NO_SENSOR_COLOR = '#e0e0e0';
+
+ ngOnInit(): void {
+ combineLatest([this.roomService.getRooms(), this.sensorService.getLatestReadings()]).subscribe(
+ ([layouts, readings]) => {
+ this.rooms.set(
+ layouts.map(layout => {
+ const reading = readings.find(r => r.roomId === layout.id);
+ return {
+ layout,
+ reading,
+ color: reading ? getCO2Color(reading.co2) : this.NO_SENSOR_COLOR,
+ };
+ })
+ );
+ }
+ );
+ }
+
+ navigateToRoom(roomId: string): void {
+ this.router.navigate(['/room', roomId]);
+ }
+
+ showTooltip(event: MouseEvent, room: RoomMapEntry): void {
+ const svg = (event.target as SVGElement).closest('svg');
+ if (!svg) return;
+ const rect = svg.getBoundingClientRect();
+ this.tooltip.set({
+ room,
+ x: event.clientX - rect.left + 12,
+ y: event.clientY - rect.top - 10,
+ });
+ }
+
+ hideTooltip(): void {
+ this.tooltip.set(null);
+ }
+}
diff --git a/ui/src/app/config/co2-levels.config.spec.ts b/ui/src/app/config/co2-levels.config.spec.ts
new file mode 100644
index 0000000..bb3372c
--- /dev/null
+++ b/ui/src/app/config/co2-levels.config.spec.ts
@@ -0,0 +1,82 @@
+import { CO2_LEVELS, getCO2Color, getCO2Level } from './co2-levels.config';
+
+describe('CO2 Levels Config', () => {
+ describe('CO2_LEVELS', () => {
+ it('should have 6 levels', () => {
+ expect(CO2_LEVELS.length).toBe(6);
+ });
+
+ it('should have Infinity as maxPpm for the last level', () => {
+ expect(CO2_LEVELS[CO2_LEVELS.length - 1].maxPpm).toBe(Infinity);
+ });
+
+ it('should have levels sorted by ascending maxPpm', () => {
+ for (let i = 0; i < CO2_LEVELS.length - 1; i++) {
+ expect(CO2_LEVELS[i].maxPpm).toBeLessThan(CO2_LEVELS[i + 1].maxPpm);
+ }
+ });
+ });
+
+ describe('getCO2Color', () => {
+ it('returns Excellent color for value below 800', () => {
+ expect(getCO2Color(400)).toBe('#4caf50');
+ expect(getCO2Color(799)).toBe('#4caf50');
+ });
+
+ it('returns Good color for value in [800, 1000)', () => {
+ expect(getCO2Color(800)).toBe('#8bc34a');
+ expect(getCO2Color(999)).toBe('#8bc34a');
+ });
+
+ it('returns Moderate color for value in [1000, 1200)', () => {
+ expect(getCO2Color(1000)).toBe('#ffc107');
+ expect(getCO2Color(1199)).toBe('#ffc107');
+ });
+
+ it('returns Poor color for value in [1200, 1500)', () => {
+ expect(getCO2Color(1200)).toBe('#ff9800');
+ });
+
+ it('returns Very Poor color for value in [1500, 2000)', () => {
+ expect(getCO2Color(1500)).toBe('#ff5722');
+ });
+
+ it('returns Critical color for value >= 2000', () => {
+ expect(getCO2Color(2000)).toBe('#f44336');
+ expect(getCO2Color(9999)).toBe('#f44336');
+ });
+ });
+
+ describe('getCO2Level', () => {
+ it('returns Excellent for 400 ppm', () => {
+ expect(getCO2Level(400).label).toBe('Excellent');
+ });
+
+ it('returns Good for 900 ppm', () => {
+ expect(getCO2Level(900).label).toBe('Good');
+ });
+
+ it('returns Moderate for 1100 ppm', () => {
+ expect(getCO2Level(1100).label).toBe('Moderate');
+ });
+
+ it('returns Poor for 1300 ppm', () => {
+ expect(getCO2Level(1300).label).toBe('Poor');
+ });
+
+ it('returns Very Poor for 1800 ppm', () => {
+ expect(getCO2Level(1800).label).toBe('Very Poor');
+ });
+
+ it('returns Critical for 2500 ppm', () => {
+ expect(getCO2Level(2500).label).toBe('Critical');
+ });
+
+ it('returned level has matching color and range', () => {
+ const level = getCO2Level(500);
+ expect(level.color).toBeTruthy();
+ expect(level.range).toBeTruthy();
+ expect(level.maxPpm).toBeGreaterThan(500);
+ });
+ });
+});
diff --git a/ui/src/app/config/co2-levels.config.ts b/ui/src/app/config/co2-levels.config.ts
index f315780..e2203fc 100644
--- a/ui/src/app/config/co2-levels.config.ts
+++ b/ui/src/app/config/co2-levels.config.ts
@@ -7,13 +7,22 @@ export interface CO2Level {
label: string;
range: string;
color: string;
+ maxPpm: number;
}
export const CO2_LEVELS: CO2Level[] = [
- { label: 'Excellent', range: '< 800 ppm', color: '#4caf50' },
- { label: 'Good', range: '800-1000 ppm', color: '#8bc34a' },
- { label: 'Moderate', range: '1000-1200 ppm', color: '#ffc107' },
- { label: 'Poor', range: '1200-1500 ppm', color: '#ff9800' },
- { label: 'Very Poor', range: '1500-2000 ppm', color: '#ff5722' },
- { label: 'Critical', range: '> 2000 ppm', color: '#f44336' },
+ { label: 'Excellent', range: '< 800 ppm', color: '#4caf50', maxPpm: 800 },
+ { label: 'Good', range: '800-1000 ppm', color: '#8bc34a', maxPpm: 1000 },
+ { label: 'Moderate', range: '1000-1200 ppm', color: '#ffc107', maxPpm: 1200 },
+ { label: 'Poor', range: '1200-1500 ppm', color: '#ff9800', maxPpm: 1500 },
+ { label: 'Very Poor', range: '1500-2000 ppm', color: '#ff5722', maxPpm: 2000 },
+ { label: 'Critical', range: '> 2000 ppm', color: '#f44336', maxPpm: Infinity },
];
+
+export function getCO2Color(ppm: number): string {
+ return (CO2_LEVELS.find(l => ppm < l.maxPpm) ?? CO2_LEVELS[CO2_LEVELS.length - 1]).color;
+}
+
+export function getCO2Level(ppm: number): CO2Level {
+ return CO2_LEVELS.find(l => ppm < l.maxPpm) ?? CO2_LEVELS[CO2_LEVELS.length - 1];
+}
diff --git a/ui/src/app/models/sensor-reading.model.ts b/ui/src/app/models/sensor-reading.model.ts
new file mode 100644
index 0000000..cf9c086
--- /dev/null
+++ b/ui/src/app/models/sensor-reading.model.ts
@@ -0,0 +1,11 @@
+export type WindowState = 'open' | 'closed';
+
+export interface SensorReading {
+ roomId: string;
+ roomName: string;
+ co2: number;
+ temperature: number;
+ humidity: number;
+ windowState: WindowState;
+ timestamp: Date;
+}
diff --git a/ui/src/app/services/room.service.spec.ts b/ui/src/app/services/room.service.spec.ts
new file mode 100644
index 0000000..1b2e56f
--- /dev/null
+++ b/ui/src/app/services/room.service.spec.ts
@@ -0,0 +1,90 @@
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { RoomService } from './room.service';
+import { environment } from '../../environments/environment';
+import { ROOM_LAYOUTS } from '../config/rooms-layout.config';
+
+describe('RoomService', () => {
+ let service: RoomService;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ });
+ service = TestBed.inject(RoomService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('getRooms()', () => {
+ it('should call GET /rooms', () => {
+ service.getRooms().subscribe();
+ const req = httpMock.expectOne(`${environment.apiUrl}/rooms`);
+ expect(req.request.method).toBe('GET');
+ req.flush([]);
+ });
+
+ it('should return HTTP response data', () => {
+ const mockData = [ROOM_LAYOUTS[0]];
+ service.getRooms().subscribe(rooms => {
+ expect(rooms).toEqual(mockData);
+ });
+ httpMock.expectOne(`${environment.apiUrl}/rooms`).flush(mockData);
+ });
+
+ it('should fall back to ROOM_LAYOUTS mock on HTTP error', done => {
+ service.getRooms().subscribe(rooms => {
+ expect(rooms.length).toBe(ROOM_LAYOUTS.length);
+ expect(rooms[0].id).toBe(ROOM_LAYOUTS[0].id);
+ done();
+ });
+ httpMock.expectOne(`${environment.apiUrl}/rooms`).error(new ProgressEvent('network error'));
+ });
+ });
+
+ describe('getRoomById()', () => {
+ it('should call GET /rooms/:id', () => {
+ service.getRoomById('A1').subscribe();
+ const req = httpMock.expectOne(`${environment.apiUrl}/rooms/A1`);
+ expect(req.request.method).toBe('GET');
+ req.flush(ROOM_LAYOUTS[0]);
+ });
+
+ it('should return the correct room from HTTP', () => {
+ const mockRoom = ROOM_LAYOUTS.find(r => r.id === 'A1')!;
+ service.getRoomById('A1').subscribe(room => {
+ expect(room).toEqual(mockRoom);
+ });
+ httpMock.expectOne(`${environment.apiUrl}/rooms/A1`).flush(mockRoom);
+ });
+
+ it('should fall back to mock room on HTTP error', done => {
+ const expected = ROOM_LAYOUTS.find(r => r.id === 'A1');
+ service.getRoomById('A1').subscribe(room => {
+ expect(room).toEqual(expected);
+ done();
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/rooms/A1`)
+ .error(new ProgressEvent('network error'));
+ });
+
+ it('should return undefined for unknown id on HTTP error', done => {
+ service.getRoomById('UNKNOWN').subscribe(room => {
+ expect(room).toBeUndefined();
+ done();
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/rooms/UNKNOWN`)
+ .error(new ProgressEvent('network error'));
+ });
+ });
+});
diff --git a/ui/src/app/services/room.service.ts b/ui/src/app/services/room.service.ts
new file mode 100644
index 0000000..22a6e64
--- /dev/null
+++ b/ui/src/app/services/room.service.ts
@@ -0,0 +1,25 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable, catchError, of } from 'rxjs';
+import { ROOM_LAYOUTS, RoomLayout } from '../config/rooms-layout.config';
+import { environment } from '../../environments/environment';
+
+@Injectable({ providedIn: 'root' })
+export class RoomService {
+ private http = inject(HttpClient);
+ private apiUrl = environment.apiUrl;
+
+ private mockRooms: RoomLayout[] = ROOM_LAYOUTS;
+
+ getRooms(): Observable {
+ return this.http
+ .get(`${this.apiUrl}/rooms`)
+ .pipe(catchError(() => of(this.mockRooms)));
+ }
+
+ getRoomById(id: string): Observable {
+ return this.http
+ .get(`${this.apiUrl}/rooms/${id}`)
+ .pipe(catchError(() => of(this.mockRooms.find(r => r.id === id))));
+ }
+}
diff --git a/ui/src/app/services/sensor.service.spec.ts b/ui/src/app/services/sensor.service.spec.ts
new file mode 100644
index 0000000..b3beec6
--- /dev/null
+++ b/ui/src/app/services/sensor.service.spec.ts
@@ -0,0 +1,170 @@
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { SensorService } from './sensor.service';
+import { environment } from '../../environments/environment';
+import { ROOM_LAYOUTS } from '../config/rooms-layout.config';
+
+describe('SensorService', () => {
+ let service: SensorService;
+ let httpMock: HttpTestingController;
+
+ const roomsWithSensors = ROOM_LAYOUTS.filter(r => r.hasSensor);
+ const firstRoomId = roomsWithSensors[0].id;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ });
+ service = TestBed.inject(SensorService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('getLatestReadings()', () => {
+ it('should call GET /sensors/latest', () => {
+ service.getLatestReadings().subscribe();
+ const req = httpMock.expectOne(`${environment.apiUrl}/sensors/latest`);
+ expect(req.request.method).toBe('GET');
+ req.flush([]);
+ });
+
+ it('should return HTTP response data', () => {
+ const mockData = [
+ {
+ roomId: 'A1',
+ roomName: 'A1',
+ co2: 700,
+ temperature: 21,
+ humidity: 45,
+ windowState: 'closed' as const,
+ timestamp: new Date(),
+ },
+ ];
+ service.getLatestReadings().subscribe(readings => {
+ expect(readings).toEqual(mockData);
+ });
+ httpMock.expectOne(`${environment.apiUrl}/sensors/latest`).flush(mockData);
+ });
+
+ it('should fall back to one reading per sensor room on HTTP error', done => {
+ service.getLatestReadings().subscribe(readings => {
+ expect(readings.length).toBe(roomsWithSensors.length);
+ done();
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/sensors/latest`)
+ .error(new ProgressEvent('network error'));
+ });
+
+ it('fallback readings should only contain rooms with sensors', done => {
+ service.getLatestReadings().subscribe(readings => {
+ const ids = readings.map(r => r.roomId);
+ const nonSensorRooms = ROOM_LAYOUTS.filter(r => !r.hasSensor).map(r => r.id);
+ nonSensorRooms.forEach(id => expect(ids).not.toContain(id));
+ done();
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/sensors/latest`)
+ .error(new ProgressEvent('network error'));
+ });
+ });
+
+ describe('getLatestReadingForRoom()', () => {
+ it('should call GET /sensors/:roomId/latest', () => {
+ service.getLatestReadingForRoom(firstRoomId).subscribe();
+ const req = httpMock.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/latest`);
+ expect(req.request.method).toBe('GET');
+ req.flush({});
+ });
+
+ it('should fall back to mock reading for given room on HTTP error', done => {
+ service.getLatestReadingForRoom(firstRoomId).subscribe(reading => {
+ expect(reading).toBeDefined();
+ expect(reading!.roomId).toBe(firstRoomId);
+ done();
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/latest`)
+ .error(new ProgressEvent('network error'));
+ });
+
+ it('should return undefined for room without mock data on HTTP error', done => {
+ service.getLatestReadingForRoom('UNKNOWN').subscribe(reading => {
+ expect(reading).toBeUndefined();
+ done();
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/sensors/UNKNOWN/latest`)
+ .error(new ProgressEvent('network error'));
+ });
+ });
+
+ describe('getHistoryForRoom()', () => {
+ it('should call GET /sensors/:roomId/history', () => {
+ service.getHistoryForRoom(firstRoomId).subscribe();
+ const req = httpMock.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/history`);
+ expect(req.request.method).toBe('GET');
+ req.flush([]);
+ });
+
+ it('should fall back to 24 entries per room on HTTP error', done => {
+ service.getHistoryForRoom(firstRoomId).subscribe(history => {
+ expect(history.length).toBe(24);
+ history.forEach(r => expect(r.roomId).toBe(firstRoomId));
+ done();
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/history`)
+ .error(new ProgressEvent('network error'));
+ });
+
+ it('fallback history entries should have required fields', done => {
+ service.getHistoryForRoom(firstRoomId).subscribe(history => {
+ const entry = history[0];
+ expect(entry.co2).toBeGreaterThan(0);
+ expect(entry.temperature).toBeGreaterThan(0);
+ expect(entry.humidity).toBeGreaterThan(0);
+ expect(['open', 'closed']).toContain(entry.windowState);
+ expect(entry.timestamp).toBeInstanceOf(Date);
+ done();
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/history`)
+ .error(new ProgressEvent('network error'));
+ });
+
+ it('fallback latest reading equals first history entry for same room', done => {
+ let latestReading: any;
+ let historyEntries: any[];
+
+ service.getLatestReadingForRoom(firstRoomId).subscribe(r => {
+ latestReading = r;
+ if (historyEntries) {
+ expect(latestReading).toEqual(historyEntries[0]);
+ done();
+ }
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/latest`)
+ .error(new ProgressEvent('network error'));
+
+ service.getHistoryForRoom(firstRoomId).subscribe(h => {
+ historyEntries = h;
+ if (latestReading) {
+ expect(latestReading).toEqual(historyEntries[0]);
+ done();
+ }
+ });
+ httpMock
+ .expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/history`)
+ .error(new ProgressEvent('network error'));
+ });
+ });
+});
diff --git a/ui/src/app/services/sensor.service.ts b/ui/src/app/services/sensor.service.ts
new file mode 100644
index 0000000..ac21111
--- /dev/null
+++ b/ui/src/app/services/sensor.service.ts
@@ -0,0 +1,45 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable, catchError, of } from 'rxjs';
+import { SensorReading, WindowState } from '../models/sensor-reading.model';
+import { ROOM_LAYOUTS } from '../config/rooms-layout.config';
+import { environment } from '../../environments/environment';
+
+const MOCK_HISTORY: SensorReading[] = ROOM_LAYOUTS.filter(r => r.hasSensor).flatMap(room =>
+ Array.from({ length: 24 }, (_, i) => ({
+ roomId: room.id,
+ roomName: room.name,
+ co2: Math.floor(Math.random() * 1600) + 400,
+ temperature: Math.round((Math.random() * 8 + 18) * 10) / 10,
+ humidity: Math.floor(Math.random() * 30 + 35),
+ windowState: (Math.random() > 0.5 ? 'open' : 'closed') as WindowState,
+ timestamp: new Date(Date.now() - i * 60 * 60 * 1000),
+ }))
+);
+
+@Injectable({ providedIn: 'root' })
+export class SensorService {
+ private http = inject(HttpClient);
+ private apiUrl = environment.apiUrl;
+
+ getLatestReadings(): Observable {
+ const latest = ROOM_LAYOUTS.filter(r => r.hasSensor).map(
+ room => MOCK_HISTORY.find(r => r.roomId === room.id)!
+ );
+ return this.http
+ .get(`${this.apiUrl}/sensors/latest`)
+ .pipe(catchError(() => of(latest)));
+ }
+
+ getLatestReadingForRoom(roomId: string): Observable {
+ return this.http
+ .get(`${this.apiUrl}/sensors/${roomId}/latest`)
+ .pipe(catchError(() => of(MOCK_HISTORY.find(r => r.roomId === roomId))));
+ }
+
+ getHistoryForRoom(roomId: string): Observable {
+ return this.http
+ .get(`${this.apiUrl}/sensors/${roomId}/history`)
+ .pipe(catchError(() => of(MOCK_HISTORY.filter(r => r.roomId === roomId))));
+ }
+}
diff --git a/ui/src/styles.scss b/ui/src/styles.scss
index 3b9cecf..7acedf6 100644
--- a/ui/src/styles.scss
+++ b/ui/src/styles.scss
@@ -1,9 +1,17 @@
/* You can add global styles to this file, and also import other style files */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
html,
body {
height: 100%;
+ overflow: hidden;
}
+
body {
margin: 0;
font-family: Roboto, 'Helvetica Neue', sans-serif;