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:
@@ -2,3 +2,4 @@
|
||||
<main>
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-footer></app-footer>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
};
|
||||
|
||||
6
ui/src/app/components/footer/footer.component.html
Normal file
6
ui/src/app/components/footer/footer.component.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<footer>
|
||||
<div class="footer-content">
|
||||
<span class="project">PI_E2EEDA — Air Quality Dashboard</span>
|
||||
<span class="credits">HES-SO © {{ year }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
28
ui/src/app/components/footer/footer.component.scss
Normal file
28
ui/src/app/components/footer/footer.component.scss
Normal 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;
|
||||
}
|
||||
38
ui/src/app/components/footer/footer.component.spec.ts
Normal file
38
ui/src/app/components/footer/footer.component.spec.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
12
ui/src/app/components/footer/footer.component.ts
Normal file
12
ui/src/app/components/footer/footer.component.ts
Normal 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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
43
ui/src/app/components/header/header.component.spec.ts
Normal file
43
ui/src/app/components/header/header.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
55
ui/src/app/components/legend/legend.component.spec.ts
Normal file
55
ui/src/app/components/legend/legend.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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() }}
|
||||
·
|
||||
{{ 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 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<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
107
ui/src/app/components/room-map/room-map.component.spec.ts
Normal file
107
ui/src/app/components/room-map/room-map.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
82
ui/src/app/config/co2-levels.config.spec.ts
Normal file
82
ui/src/app/config/co2-levels.config.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
11
ui/src/app/models/sensor-reading.model.ts
Normal file
11
ui/src/app/models/sensor-reading.model.ts
Normal 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;
|
||||
}
|
||||
90
ui/src/app/services/room.service.spec.ts
Normal file
90
ui/src/app/services/room.service.spec.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
25
ui/src/app/services/room.service.ts
Normal file
25
ui/src/app/services/room.service.ts
Normal 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))));
|
||||
}
|
||||
}
|
||||
170
ui/src/app/services/sensor.service.spec.ts
Normal file
170
ui/src/app/services/sensor.service.spec.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
45
ui/src/app/services/sensor.service.ts
Normal file
45
ui/src/app/services/sensor.service.ts
Normal 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))));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user