feat(ui): implement interactive room map with REST services and unit tests

- Add SensorReading model with CO2, temperature, humidity, window state, timestamp
- Add RoomService and SensorService with HTTP calls and mock data fallback
- Rewrite RoomMapComponent: SVG floor plan, CO2-based coloring, tooltips, navigation
- Rewrite RoomDetailsPanelComponent: sensor history table with pagination (8 rows/page)
- Add FooterComponent with current year, fix header/footer sticky layout
- Update LegendComponent template and styles for two-line item layout
- Add provideHttpClient() to app.config.ts
- Add getCO2Color() and getCO2Level() helpers to co2-levels.config.ts
- Add 85 unit tests across all components, services, and config helpers

Closes #28
Closes #29
This commit is contained in:
khalil-bot
2026-04-15 19:30:32 +02:00
parent 387607a664
commit 27a38366c3
30 changed files with 1625 additions and 42 deletions

View File

@@ -2,3 +2,4 @@
<main>
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>

View File

@@ -1,4 +1,13 @@
:host {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
main {
min-height: calc(100vh - 64px);
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: #fafafa;
}

View File

@@ -29,4 +29,11 @@ describe('AppComponent', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-header')).toBeTruthy();
});
it('should render footer', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-footer')).toBeTruthy();
});
});

View File

@@ -1,11 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, HeaderComponent],
imports: [RouterOutlet, HeaderComponent, FooterComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})

View File

@@ -1,5 +1,6 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
@@ -9,5 +10,6 @@ export const appConfig: ApplicationConfig = {
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(),
],
};

View File

@@ -0,0 +1,6 @@
<footer>
<div class="footer-content">
<span class="project">PI_E2EEDA &mdash; Air Quality Dashboard</span>
<span class="credits">HES-SO &copy; {{ year }}</span>
</div>
</footer>

View File

@@ -0,0 +1,28 @@
footer {
background: #263238;
color: #90a4ae;
font-size: 13px;
flex-shrink: 0;
z-index: 100;
box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.15);
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 14px 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.project {
color: #cfd8dc;
font-weight: 500;
}
.credits {
color: #78909c;
}

View File

@@ -0,0 +1,38 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FooterComponent } from './footer.component';
describe('FooterComponent', () => {
let fixture: ComponentFixture<FooterComponent>;
let compiled: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FooterComponent],
}).compileComponents();
fixture = TestBed.createComponent(FooterComponent);
fixture.detectChanges();
compiled = fixture.nativeElement as HTMLElement;
});
it('should create', () => {
expect(fixture.componentInstance).toBeTruthy();
});
it('should render a <footer> element', () => {
expect(compiled.querySelector('footer')).toBeTruthy();
});
it('should display the project name', () => {
expect(compiled.textContent).toContain('PI_E2EEDA');
});
it('should display the current year', () => {
const currentYear = new Date().getFullYear().toString();
expect(compiled.textContent).toContain(currentYear);
});
it('should expose the correct year property', () => {
expect(fixture.componentInstance.year).toBe(new Date().getFullYear());
});
});

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-footer',
standalone: true,
templateUrl: './footer.component.html',
styleUrl: './footer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FooterComponent {
readonly year = new Date().getFullYear();
}

View File

@@ -1,7 +1,9 @@
header {
background: #00bcd4;
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
z-index: 100;
}
.header-content {

View File

@@ -0,0 +1,43 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { HeaderComponent } from './header.component';
describe('HeaderComponent', () => {
let fixture: ComponentFixture<HeaderComponent>;
let compiled: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HeaderComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(HeaderComponent);
fixture.detectChanges();
compiled = fixture.nativeElement as HTMLElement;
});
it('should create', () => {
expect(fixture.componentInstance).toBeTruthy();
});
it('should render a <header> element', () => {
expect(compiled.querySelector('header')).toBeTruthy();
});
it('should contain a navigation link to /dashboard', () => {
const link = compiled.querySelector(
'a[routerLink="/dashboard"], a[ng-reflect-router-link="/dashboard"], h1 a'
);
expect(link).toBeTruthy();
});
it('should display the project name', () => {
const text = compiled.textContent ?? '';
expect(text).toContain('PI_E2EEDA');
});
it('should render a <nav> element', () => {
expect(compiled.querySelector('nav')).toBeTruthy();
});
});

View File

@@ -4,8 +4,10 @@
@for (level of levels; track level.label) {
<div class="legend-item">
<div class="color-box" [style.background-color]="level.color"></div>
<span class="label">{{ level.label }}</span>
<span class="range">{{ level.range }}</span>
<div class="legend-text">
<span class="label">{{ level.label }}</span>
<span class="range">{{ level.range }}</span>
</div>
</div>
}
</div>

View File

@@ -15,28 +15,37 @@ h3 {
.legend-items {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.legend-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
gap: 10px;
}
.color-box {
width: 24px;
height: 24px;
width: 20px;
height: 20px;
border-radius: 4px;
flex-shrink: 0;
}
.legend-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.label {
font-size: 13px;
font-weight: 500;
min-width: 100px;
color: rgba(0, 0, 0, 0.87);
white-space: nowrap;
}
.range {
color: rgba(0, 0, 0, 0.6);
font-size: 11px;
color: rgba(0, 0, 0, 0.5);
white-space: nowrap;
}

View File

@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LegendComponent } from './legend.component';
import { CO2_LEVELS } from '../../config/co2-levels.config';
describe('LegendComponent', () => {
let fixture: ComponentFixture<LegendComponent>;
let compiled: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LegendComponent],
}).compileComponents();
fixture = TestBed.createComponent(LegendComponent);
fixture.detectChanges();
compiled = fixture.nativeElement as HTMLElement;
});
it('should create', () => {
expect(fixture.componentInstance).toBeTruthy();
});
it('should expose CO2_LEVELS as levels', () => {
expect(fixture.componentInstance.levels).toEqual(CO2_LEVELS);
});
it('should render one legend-item per CO2 level', () => {
const items = compiled.querySelectorAll('.legend-item');
expect(items.length).toBe(CO2_LEVELS.length);
});
it('should display each level label', () => {
CO2_LEVELS.forEach(level => {
expect(compiled.textContent).toContain(level.label);
});
});
it('should display each level range', () => {
CO2_LEVELS.forEach(level => {
expect(compiled.textContent).toContain(level.range);
});
});
it('should render color boxes with correct background colors', () => {
const colorBoxes = compiled.querySelectorAll<HTMLElement>('.color-box');
expect(colorBoxes.length).toBe(CO2_LEVELS.length);
colorBoxes.forEach(box => {
expect(box.style.backgroundColor).toBeTruthy();
});
});
it('should have a title', () => {
expect(compiled.querySelector('h3')).toBeTruthy();
});
});

View File

@@ -1,5 +1,102 @@
<div class="room-details-container">
<h1>Room Details - {{ roomId }}</h1>
<p>Detailed room information coming soon.</p>
<a routerLink="/dashboard">← Back to Dashboard</a>
<div class="details-container">
<a routerLink="/dashboard" class="back-link">← Back to Dashboard</a>
@if (room(); as r) {
<div
class="room-header"
[style.border-left-color]="latestReading() ? getCO2Color(latestReading()!.co2) : '#e0e0e0'"
>
<h1>{{ r.name }}</h1>
</div>
@if (latestReading(); as reading) {
<div class="status-bar" [style.background-color]="getCO2Color(reading.co2)">
<span class="status-label">{{ getCO2Level(reading.co2).label }}</span>
<span class="status-ppm">{{ reading.co2 }} ppm CO₂</span>
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-icon">🌫️</div>
<div class="metric-value">{{ reading.co2 }}</div>
<div class="metric-unit">ppm CO₂</div>
</div>
<div class="metric-card">
<div class="metric-icon">🌡️</div>
<div class="metric-value">{{ reading.temperature }}</div>
<div class="metric-unit">°C</div>
</div>
<div class="metric-card">
<div class="metric-icon">💧</div>
<div class="metric-value">{{ reading.humidity }}</div>
<div class="metric-unit">% Humidity</div>
</div>
<div class="metric-card" [class.open]="reading.windowState === 'open'">
<div class="metric-icon">🪟</div>
<div class="metric-value">{{ reading.windowState }}</div>
<div class="metric-unit">Windows</div>
</div>
</div>
<div class="timestamp">
Last updated: {{ reading.timestamp | date: 'HH:mm:ss, dd MMM yyyy' : 'UTC' }} UTC
</div>
} @else {
<div class="no-sensor">No sensor installed in this room.</div>
}
@if (history().length > 0) {
<section class="history-section">
<h2>Last 24h readings</h2>
<div class="history-table-wrapper">
<table class="history-table">
<thead>
<tr>
<th>Time (UTC)</th>
<th>CO₂ (ppm)</th>
<th>Temp (°C)</th>
<th>Humidity (%)</th>
<th>Windows</th>
</tr>
</thead>
<tbody>
@for (entry of pagedHistory(); track entry.timestamp) {
<tr>
<td>{{ entry.timestamp | date: 'HH:mm' : 'UTC' }}</td>
<td>
<span class="co2-badge" [style.background-color]="getCO2Color(entry.co2)">
{{ entry.co2 }}
</span>
</td>
<td>{{ entry.temperature }}</td>
<td>{{ entry.humidity }}</td>
<td>{{ entry.windowState }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pagination">
<button class="page-btn" (click)="prevPage()" [disabled]="currentPage() === 0">
← Prev
</button>
<span class="page-info">
Page {{ currentPage() + 1 }} / {{ totalPages() }}
&nbsp;·&nbsp;
{{ history().length }} entries
</span>
<button
class="page-btn"
(click)="nextPage()"
[disabled]="currentPage() === totalPages() - 1"
>
Next →
</button>
</div>
</section>
}
} @else {
<div class="not-found">Room not found.</div>
}
</div>

View File

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

View File

@@ -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<RoomDetailsPanelComponent>;
let component: RoomDetailsPanelComponent;
let roomServiceSpy: jasmine.SpyObj<RoomService>;
let sensorServiceSpy: jasmine.SpyObj<SensorService>;
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 815', () => {
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<HTMLButtonElement>('.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<HTMLButtonElement>('.page-btn:last-of-type');
expect(nextBtn?.disabled).toBeTrue();
});
});
});

View File

@@ -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<RoomLayout | undefined>(undefined);
latestReading = signal<SensorReading | undefined>(undefined);
history = signal<SensorReading[]>([]);
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);
}
}

View File

@@ -1,4 +1,92 @@
<div class="room-map-container">
<h1>Room Map</h1>
<p>Interactive 2D SVG map will be displayed here.</p>
<div class="dashboard-layout">
<div class="map-wrapper">
<h2 class="map-title">Building Floor Plan</h2>
<div class="svg-container">
<svg viewBox="0 0 1230 760" xmlns="http://www.w3.org/2000/svg" (mouseleave)="hideTooltip()">
<!-- Section labels -->
<text x="160" y="48" class="section-label">Espace B</text>
<text x="690" y="48" class="section-label">Espace A</text>
<!-- Corridor separator -->
<rect x="290" y="60" width="100" height="680" class="corridor" />
<text x="340" y="408" class="corridor-label">Corridor</text>
@for (room of rooms(); track room.layout.id) {
<g
class="room-group"
[class.no-sensor]="!room.layout.hasSensor"
(click)="navigateToRoom(room.layout.id)"
(mouseenter)="showTooltip($event, room)"
(mousemove)="showTooltip($event, room)"
(mouseleave)="hideTooltip()"
>
<rect
[attr.x]="room.layout.x"
[attr.y]="room.layout.y"
[attr.width]="room.layout.width"
[attr.height]="room.layout.height"
[attr.fill]="room.color"
class="room-rect"
rx="6"
/>
<text
[attr.x]="room.layout.x + room.layout.width / 2"
[attr.y]="room.layout.y + room.layout.height / 2 - 10"
class="room-id"
>
{{ room.layout.id }}
</text>
<text
[attr.x]="room.layout.x + room.layout.width / 2"
[attr.y]="room.layout.y + room.layout.height / 2 + 12"
class="room-co2"
>
@if (room.reading) {
{{ room.reading.co2 }} ppm
} @else {
No sensor
}
</text>
@if (room.reading) {
<text
[attr.x]="room.layout.x + room.layout.width / 2"
[attr.y]="room.layout.y + room.layout.height / 2 + 30"
class="room-window"
>
🪟 {{ room.reading.windowState }}
</text>
}
</g>
}
</svg>
<!-- Tooltip -->
@if (tooltip(); as t) {
<div class="tooltip" [style.left.px]="t.x" [style.top.px]="t.y">
<div class="tooltip-title">{{ t.room.layout.name }}</div>
@if (t.room.reading) {
<div class="tooltip-row">
<span>CO₂</span><strong>{{ t.room.reading.co2 }} ppm</strong>
</div>
<div class="tooltip-row">
<span>Temp.</span><strong>{{ t.room.reading.temperature }} °C</strong>
</div>
<div class="tooltip-row">
<span>Humidity</span><strong>{{ t.room.reading.humidity }} %</strong>
</div>
<div class="tooltip-row">
<span>Windows</span><strong>{{ t.room.reading.windowState }}</strong>
</div>
} @else {
<div class="tooltip-no-sensor">No sensor installed</div>
}
</div>
}
</div>
</div>
<aside class="sidebar">
<app-legend />
</aside>
</div>

View File

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

View File

@@ -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<RoomMapComponent>;
let component: RoomMapComponent;
let roomServiceSpy: jasmine.SpyObj<RoomService>;
let sensorServiceSpy: jasmine.SpyObj<SensorService>;
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();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<RoomLayout[]> {
return this.http
.get<RoomLayout[]>(`${this.apiUrl}/rooms`)
.pipe(catchError(() => of(this.mockRooms)));
}
getRoomById(id: string): Observable<RoomLayout | undefined> {
return this.http
.get<RoomLayout>(`${this.apiUrl}/rooms/${id}`)
.pipe(catchError(() => of(this.mockRooms.find(r => r.id === id))));
}
}

View File

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

View File

@@ -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<SensorReading[]> {
const latest = ROOM_LAYOUTS.filter(r => r.hasSensor).map(
room => MOCK_HISTORY.find(r => r.roomId === room.id)!
);
return this.http
.get<SensorReading[]>(`${this.apiUrl}/sensors/latest`)
.pipe(catchError(() => of(latest)));
}
getLatestReadingForRoom(roomId: string): Observable<SensorReading | undefined> {
return this.http
.get<SensorReading>(`${this.apiUrl}/sensors/${roomId}/latest`)
.pipe(catchError(() => of(MOCK_HISTORY.find(r => r.roomId === roomId))));
}
getHistoryForRoom(roomId: string): Observable<SensorReading[]> {
return this.http
.get<SensorReading[]>(`${this.apiUrl}/sensors/${roomId}/history`)
.pipe(catchError(() => of(MOCK_HISTORY.filter(r => r.roomId === roomId))));
}
}

View File

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