From 5d921a9b1eac48f8abd88caebc36328147e79344 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 16:43:15 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20vue=20d=C3=A9tail=20d'un=20site=20(?= =?UTF-8?q?#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la page de détail d'un site (fiche, mesure instantanée, jauge de consommation, historique) en remplacement du placeholder. Les champs null sont affichés explicitement avec leur raison plutôt que masqués. Ajoute l'endpoint GET /sites/{id}/current côté backend, qui retourne la dernière mesure connue d'un site sans filtre temporel, conformément au contrat de l'issue. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012i5NteMLRZgfTAKB5GD37X --- apps/backend/app/api/v1/endpoints/sites.py | 22 ++- apps/backend/app/repositories/reading.py | 9 + apps/backend/app/services/reading.py | 3 + apps/backend/openapi.json | 95 ++++++++++ apps/backend/tests/api/test_sites.py | 80 ++++++++- .../tests/repositories/test_reading.py | 25 +++ apps/backend/tests/services/test_reading.py | 24 +++ apps/frontend/src/app/app.routes.ts | 4 +- .../core/services/readings.service.spec.ts | 72 ++++++++ .../src/app/core/services/readings.service.ts | 24 +++ .../app/core/services/sites.service.spec.ts | 19 ++ .../src/app/core/services/sites.service.ts | 4 + .../site-detail-placeholder.html | 19 -- .../site-detail-placeholder.scss | 28 --- .../site-detail-placeholder.spec.ts | 42 ----- .../site-detail-placeholder.ts | 19 -- .../sites/site-detail/site-detail.html | 71 ++++++++ .../sites/site-detail/site-detail.scss | 102 +++++++++++ .../sites/site-detail/site-detail.spec.ts | 170 ++++++++++++++++++ .../features/sites/site-detail/site-detail.ts | 161 +++++++++++++++++ .../reading-history-chart.html | 1 + .../reading-history-chart.scss | 4 + .../reading-history-chart.spec.ts | 80 +++++++++ .../reading-history-chart.ts | 80 +++++++++ .../src/app/shared/models/reading.model.ts | 23 +++ 25 files changed, 1067 insertions(+), 114 deletions(-) create mode 100644 apps/frontend/src/app/core/services/readings.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/readings.service.ts delete mode 100644 apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html delete mode 100644 apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss delete mode 100644 apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts delete mode 100644 apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts create mode 100644 apps/frontend/src/app/features/sites/site-detail/site-detail.html create mode 100644 apps/frontend/src/app/features/sites/site-detail/site-detail.scss create mode 100644 apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts create mode 100644 apps/frontend/src/app/features/sites/site-detail/site-detail.ts create mode 100644 apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html create mode 100644 apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss create mode 100644 apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts create mode 100644 apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts create mode 100644 apps/frontend/src/app/shared/models/reading.model.ts diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..bc13d9c 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, HTTPException, status -from app.api.deps import LecteurDep, SiteServiceDep +from app.api.deps import LecteurDep, ReadingServiceDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse +from app.schemas.reading import ReadingResponse from app.schemas.site import SiteResponse from app.services.site import SiteNotFoundError @@ -34,3 +35,22 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur return SiteResponse.model_validate(site) + + +@router.get( + "/{site_id}/current", + response_model=ReadingResponse | None, + summary="Dernière mesure connue d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current( + site_id: str, _: LecteurDep, sites: SiteServiceDep, readings: ReadingServiceDep +) -> ReadingResponse | None: + try: + await sites.get_by_id(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + derniere = await readings.get_latest(site_id) + return ReadingResponse.model_validate(derniere) if derniere is not None else None diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 71352da..d2d06a1 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -21,6 +21,15 @@ class ReadingRepository: ) return (await self._session.execute(requete)).scalars().all() + async def latest_for_site(self, site_id: str) -> Reading | None: + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc(), Reading.reading_id.desc()) + .limit(1) + ) + return (await self._session.scalars(requete)).first() + async def list_history( self, *, diff --git a/apps/backend/app/services/reading.py b/apps/backend/app/services/reading.py index 818c202..7d746c2 100644 --- a/apps/backend/app/services/reading.py +++ b/apps/backend/app/services/reading.py @@ -20,6 +20,9 @@ class ReadingService: def __init__(self, *, readings: ReadingRepository) -> None: self._readings = readings + async def get_latest(self, site_id: str) -> Reading | None: + return await self._readings.latest_for_site(site_id) + async def list_history( self, *, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 25fc9ee..ab4ff74 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1115,6 +1115,101 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure connue d'un site", + "operationId": "get_current_api_v1_sites__site_id__current_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReadingResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Current Api V1 Sites Site Id Current Get" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "Aucun site ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/alerts": { "get": { "tags": [ diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..5980b0b 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,14 +1,15 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest from fastapi import FastAPI from httpx import AsyncClient -from app.api.deps import get_current_principal, get_site_service +from app.api.deps import get_current_principal, get_reading_service, get_site_service from app.core.principal import Principal from app.core.roles import AccountKind, Role -from app.models.energy import Site +from app.models.energy import Reading, Site from app.services.site import SiteNotFoundError @@ -47,6 +48,26 @@ class FauxService: return self.site +def reading(site_id: str = "site-1") -> Reading: + return Reading( + reading_id=1, + site_id=site_id, + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + source="csv", + consumption_kw=42.5, + data_quality="good", + raw_data={}, + ) + + +class FauxReadingService: + def __init__(self, derniere: Reading | None) -> None: + self._derniere = derniere + + async def get_latest(self, site_id: str) -> Reading | None: + return self._derniere + + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: app.dependency_overrides[get_current_principal] = lambda: principal() @@ -67,6 +88,19 @@ def servi( app.dependency_overrides.pop(get_site_service, None) +@pytest.fixture +def readings_servis( + app: FastAPI, lecteur_connecte: None +) -> Iterator[Callable[[Reading | None], FauxReadingService]]: + def installe(derniere: Reading | None) -> FauxReadingService: + service = FauxReadingService(derniere) + app.dependency_overrides[get_reading_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_reading_service, None) + + async def test_list_sites_returns_the_sites( servi: Callable[..., FauxService], client: AsyncClient ) -> None: @@ -139,3 +173,45 @@ async def test_get_site_returns_404_when_the_session_finds_nothing( response = await client.get("/api/v1/sites/inconnu") assert response.status_code == 404 + + +async def test_get_current_returns_the_latest_reading_regardless_of_its_age( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi() + readings_servis(reading()) + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["consumption_kw"] == 42.5 + + +async def test_get_current_returns_null_when_the_site_has_no_reading( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi() + readings_servis(None) + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + assert response.json() is None + + +async def test_get_current_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi(SiteNotFoundError("site-inconnu")) + readings_servis(None) + + response = await client.get("/api/v1/sites/site-inconnu/current") + + assert response.status_code == 404 diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 150fa29..70af2d2 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -88,6 +88,31 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> assert identifiants == {premier, second} +async def test_latest_for_site_ignores_how_old_the_reading_is(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2024, 1, 1, tzinfo=UTC)) + recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2024, 12, 31, tzinfo=UTC) + ) + + resultat = await depot.latest_for_site(site.site_id) + await session.rollback() + + assert resultat is not None + assert resultat.reading_id == recente.reading_id + + +async def test_latest_for_site_returns_none_when_the_site_has_no_reading( + session: AsyncSession, +) -> None: + depot = ReadingRepository(session) + + resultat = await depot.latest_for_site(identifiant_site()) + + assert resultat is None + + async def test_list_history_orders_the_readings_by_timestamp_descending( session: AsyncSession, ) -> None: diff --git a/apps/backend/tests/services/test_reading.py b/apps/backend/tests/services/test_reading.py index a3f0826..1295aa9 100644 --- a/apps/backend/tests/services/test_reading.py +++ b/apps/backend/tests/services/test_reading.py @@ -28,6 +28,11 @@ class FakeRepository: def __init__(self, readings: list[Reading]) -> None: self._readings = readings self.appels: list[tuple[str | None, datetime, datetime, int, int]] = [] + self.site_interroge: str | None = None + + async def latest_for_site(self, site_id: str) -> Reading | None: + self.site_interroge = site_id + return self._readings[0] if self._readings else None async def list_history( self, @@ -42,6 +47,25 @@ class FakeRepository: return self._readings +async def test_get_latest_relays_the_repository_reading() -> None: + depot = FakeRepository([reading(1)]) + service = ReadingService(readings=depot) + + lecture = await service.get_latest("site-1") + + assert lecture is not None + assert lecture.reading_id == 1 + assert depot.site_interroge == "site-1" + + +async def test_get_latest_returns_none_when_the_site_has_no_reading() -> None: + service = ReadingService(readings=FakeRepository([])) + + lecture = await service.get_latest("site-1") + + assert lecture is None + + async def test_list_history_returns_the_repository_readings() -> None: service = ReadingService(readings=FakeRepository([reading(1), reading(2)])) diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index e619268..1cb0752 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -21,8 +21,6 @@ export const routes: Routes = [ path: 'sites/:siteId', canActivate: [authGuard], loadComponent: () => - import('./features/sites/site-detail-placeholder/site-detail-placeholder').then( - (m) => m.SiteDetailPlaceholder, - ), + import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, ]; diff --git a/apps/frontend/src/app/core/services/readings.service.spec.ts b/apps/frontend/src/app/core/services/readings.service.spec.ts new file mode 100644 index 0000000..98a3177 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { ReadingsService } from './readings.service'; +import { environment } from '../../../environments/environment'; + +describe('ReadingsService', () => { + let service: ReadingsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(ReadingsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("demande la dernière lecture connue du site, quel que soit son âge", () => { + let result: unknown; + service.getLatest('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + expect(req.request.method).toBe('GET'); + + req.flush({ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }); + + expect((result as { reading_id: number }).reading_id).toBe(1); + }); + + it("retourne null quand le site n'a aucune lecture", () => { + let result: unknown; + service.getLatest('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + req.flush(null); + + expect(result).toBeNull(); + }); + + it("demande l'historique du site avec la fenêtre temporelle donnée", () => { + let result: unknown; + service.getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z').subscribe( + (r) => (result = r), + ); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', + ); + expect(req.request.params.get('site_id')).toBe('SITE001'); + expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z'); + expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z'); + + req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]); + + expect((result as unknown[]).length).toBe(1); + }); + + it("ne pose pas de paramètres start/end quand ils sont omis", () => { + service.getHistory('SITE001').subscribe(); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', + ); + expect(req.request.params.has('start')).toBe(false); + expect(req.request.params.has('end')).toBe(false); + + req.flush([]); + }); +}); diff --git a/apps/frontend/src/app/core/services/readings.service.ts b/apps/frontend/src/app/core/services/readings.service.ts new file mode 100644 index 0000000..d692237 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.ts @@ -0,0 +1,24 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Reading } from '../../shared/models/reading.model'; + +@Service() +export class ReadingsService { + private http = inject(HttpClient); + + getLatest(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); + } + + getHistory(siteId: string, start?: string, end?: string) { + let params = new HttpParams().set('site_id', siteId); + if (start) { + params = params.set('start', start); + } + if (end) { + params = params.set('end', end); + } + return this.http.get(`${environment.apiUrl}/readings`, { params }); + } +} diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts index 45aee2c..8eabeb2 100644 --- a/apps/frontend/src/app/core/services/sites.service.spec.ts +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -38,4 +38,23 @@ describe('SitesService', () => { expect((result as { site_id: string }[])[0].site_id).toBe('SITE001'); }); + + it('appelle le bon endpoint et retourne un site', () => { + let result: unknown; + service.getSite('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`); + expect(req.request.method).toBe('GET'); + + req.flush({ + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }); + + expect((result as { site_id: string }).site_id).toBe('SITE001'); + }); }); diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts index 85754cc..449bd29 100644 --- a/apps/frontend/src/app/core/services/sites.service.ts +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -10,4 +10,8 @@ export class SitesService { getSites() { return this.http.get(`${environment.apiUrl}/sites`); } + + getSite(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}`); + } } diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html deleted file mode 100644 index 9533177..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html +++ /dev/null @@ -1,19 +0,0 @@ -
- - -
- - -

Site {{ siteId() }}

-
- - -

Le détail de ce site est à venir (voir issue #51).

- Retour aux sites -
-
diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss deleted file mode 100644 index ca04d4a..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss +++ /dev/null @@ -1,28 +0,0 @@ -:host { - display: block; - color: var(--color-text); - padding: 2.5rem 2rem; - max-width: 640px; - margin: 0 auto; -} - -.site-detail-placeholder__header { - display: flex; - align-items: center; - gap: 0.85rem; - margin-bottom: 1.5rem; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - } -} - -.site-detail-placeholder__logo { - font-size: 1.3rem; -} - -ev-card p { - margin: 0 0 0.75rem; -} diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts deleted file mode 100644 index f229e36..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; -import { BehaviorSubject } from 'rxjs'; -import { SiteDetailPlaceholder } from './site-detail-placeholder'; - -describe('SiteDetailPlaceholder', () => { - it("affiche l'identifiant du site depuis la route", () => { - const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' })); - TestBed.configureTestingModule({ - imports: [SiteDetailPlaceholder], - providers: [ - provideRouter([]), - { provide: ActivatedRoute, useValue: { paramMap } }, - ], - }); - - const fixture = TestBed.createComponent(SiteDetailPlaceholder); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain('SITE001'); - }); - - it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => { - const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' })); - TestBed.configureTestingModule({ - imports: [SiteDetailPlaceholder], - providers: [ - provideRouter([]), - { provide: ActivatedRoute, useValue: { paramMap } }, - ], - }); - - const fixture = TestBed.createComponent(SiteDetailPlaceholder); - fixture.detectChanges(); - - paramMap.next(convertToParamMap({ siteId: 'SITE002' })); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain('SITE002'); - expect(fixture.nativeElement.textContent).not.toContain('SITE001'); - }); -}); diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts deleted file mode 100644 index 39e53dc..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { map } from 'rxjs'; -import { Card } from '../../../shared/components/ui/card/card'; -import { Brand } from '../../../shared/components/ui/brand/brand'; - -@Component({ - selector: 'app-site-detail-placeholder', - standalone: true, - imports: [RouterLink, Card, Brand], - templateUrl: './site-detail-placeholder.html', - styleUrl: './site-detail-placeholder.scss', -}) -export class SiteDetailPlaceholder { - private route = inject(ActivatedRoute); - - siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId')))); -} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html new file mode 100644 index 0000000..f2fbae9 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -0,0 +1,71 @@ +
+ + +
+ + +
+

{{ site()?.site_name ?? siteId() }}

+ @if (site(); as s) { +

+ {{ s.site_type }} · {{ s.location || 'Localisation inconnue' }} +

+ } +
+ @if (site(); as s) { + {{ s.status ?? '-' }} + } +
+ + @if (error(); as message) { + + } + + @if (site(); as s) { +
+ + Consommation vs capacité + + + {{ latestReading()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW + + + + + Mesure instantanée +
+ @for (metric of metrics(); track metric.key) { +
+
{{ metric.label }}
+ @if (metric.value !== null) { +
{{ metric.value }}
+ } @else { +
+ Indisponible + ({{ metric.reason }}) +
+ } +
+ } +
+
+
+ + @if (history().length > 0) { +
+

Historique de consommation

+ +
+ } + } + + Retour aux sites +
diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss new file mode 100644 index 0000000..fbb17c6 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -0,0 +1,102 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.site-detail__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.site-detail__logo { + font-size: 1.3rem; +} + +.site-detail__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.banner-error { + display: block; + margin: 0 0 1.5rem; +} + +.overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + margin-bottom: 2.5rem; +} + +.card { + padding: 1.25rem; + gap: 0.35rem; +} + +.card--gauge { + align-items: center; + text-align: center; +} + +.card__label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.card__value { + font-size: 1.6rem; + font-weight: 700; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem 1.5rem; + margin: 0.5rem 0 0; +} + +.metric { + dt { + font-size: 0.75rem; + color: var(--color-text-muted); + } + + dd { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + } +} + +.metric__unavailable { + color: var(--color-text-muted); + font-weight: 400; +} + +.metric__reason { + font-size: 0.8rem; +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.chart-section { + margin-bottom: 2rem; +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts new file mode 100644 index 0000000..11e6c8f --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -0,0 +1,170 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { SiteDetail } from './site-detail'; +import { SitesService } from '../../../core/services/sites.service'; +import { ReadingsService } from '../../../core/services/readings.service'; + +const SITE = { + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', +}; + +const READING_COMPLETE = { + reading_id: 1, + site_id: 'SITE001', + timestamp: '2026-09-17T10:00:00Z', + source: 'api_current' as const, + consumption_kw: 120, + consumption_kwh: null, + consumption_euros: null, + voltage_v: 230, + current_a: 12, + power_factor: 0.95, + temperature_celsius: 22, + humidity_percent: 55, + solar_irradiance_wm2: null, + is_working_hours: true, + data_quality: 'good' as const, + null_reasons: null, + imputed_values: null, + imputation_method: null, +}; + +function setup( + siteId: string, + sitesMock: Partial, + readingsMock: Partial, +) { + const paramMap = new BehaviorSubject(convertToParamMap({ siteId })); + TestBed.configureTestingModule({ + imports: [SiteDetail], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { paramMap } }, + { provide: SitesService, useValue: sitesMock }, + { provide: ReadingsService, useValue: readingsMock }, + ], + }); + return { fixture: TestBed.createComponent(SiteDetail), paramMap }; +} + +describe('SiteDetail', () => { + it('charge le site, la dernière lecture et son historique au démarrage', () => { + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + getHistory: vi.fn().mockReturnValue(of([READING_COMPLETE])), + }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + expect(fixture.componentInstance.latestReading()?.consumption_kw).toBe(120); + expect(fixture.componentInstance.history().length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it("signale l'indisponibilité quand un des appels échoue", () => { + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + getHistory: vi.fn().mockReturnValue(of([])), + }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.site()).toBeNull(); + }); + + it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { + const readingPartielle = { + ...READING_COMPLETE, + voltage_v: null, + current_a: null, + power_factor: null, + null_reasons: ['electrical_sensor_failure'], + }; + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { + getLatest: vi.fn().mockReturnValue(of(readingPartielle)), + getHistory: vi.fn().mockReturnValue(of([readingPartielle])), + }, + ); + + fixture.detectChanges(); + + const tension = fixture.componentInstance + .metrics() + .find((m) => m.key === 'voltage_v'); + expect(tension?.value).toBeNull(); + expect(tension?.reason).toBe('capteur électrique en panne'); + + const html = fixture.nativeElement.textContent; + expect(html).toContain('Indisponible'); + expect(html).toContain('capteur électrique en panne'); + }); + + it('recharge les données quand le paramètre de route siteId change', () => { + const getSite = vi.fn().mockReturnValue(of(SITE)); + const { fixture, paramMap } = setup( + 'SITE001', + { getSite }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + getHistory: vi.fn().mockReturnValue(of([])), + }, + ); + + fixture.detectChanges(); + paramMap.next(convertToParamMap({ siteId: 'SITE002' })); + fixture.detectChanges(); + + expect(getSite).toHaveBeenCalledWith('SITE002'); + }); + + it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).toHaveBeenCalledWith( + 'SITE001', + '2026-09-16T10:00:00.000Z', + '2026-09-17T10:00:00Z', + ); + }); + + it("ne fixe aucune fenêtre d'historique quand le site n'a aucune lecture", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { getLatest: vi.fn().mockReturnValue(of(null)), getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).toHaveBeenCalledWith('SITE001', undefined, undefined); + expect(fixture.componentInstance.latestReading()).toBeNull(); + }); +}); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts new file mode 100644 index 0000000..fa267d0 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -0,0 +1,161 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { catchError, EMPTY, map, Observable, switchMap } from 'rxjs'; +import { SitesService } from '../../../core/services/sites.service'; +import { ReadingsService } from '../../../core/services/readings.service'; +import { Site } from '../../../shared/models/site.model'; +import { Reading } from '../../../shared/models/reading.model'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge'; +import { Brand } from '../../../shared/components/ui/brand/brand'; +import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge'; +import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart'; + +const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.'; +const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000; + +const TON_PAR_STATUT: Record = { + actif: 'success', + maintenance: 'warning', + hors_service: 'danger', +}; + +type MetricKey = + | 'consumption_kw' + | 'voltage_v' + | 'current_a' + | 'power_factor' + | 'temperature_celsius' + | 'humidity_percent'; + +interface MetricDef { + key: MetricKey; + label: string; + format: (value: number) => string; +} + +const METRIC_DEFS: MetricDef[] = [ + { key: 'consumption_kw', label: 'Consommation', format: (v) => `${v.toFixed(1)} kW` }, + { key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` }, + { key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` }, + { key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) }, + { key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` }, + { key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` }, +]; + +// Contrainte : miroir de `RAISON_VERS_CAPTEUR`/`CHAMPS_PAR_CAPTEUR` côté backend +// (apps/backend/app/services/sensor.py) - `null_reasons` porte le code de panne du capteur, +// jamais le nom du champ. +const RAISONS_PAR_CHAMP: Record = { + consumption_kw: ['consumption_sensor_failure', 'network_loss'], + voltage_v: ['electrical_sensor_failure', 'network_loss'], + current_a: ['electrical_sensor_failure', 'network_loss'], + power_factor: ['electrical_sensor_failure', 'network_loss'], + temperature_celsius: ['temperature_sensor_failure', 'network_loss'], + humidity_percent: ['humidity_sensor_failure', 'network_loss'], +}; + +const LIBELLE_PAR_RAISON: Record = { + consumption_sensor_failure: 'capteur de consommation en panne', + electrical_sensor_failure: 'capteur électrique en panne', + temperature_sensor_failure: 'capteur de température en panne', + humidity_sensor_failure: 'capteur d\'humidité en panne', + network_loss: 'perte réseau', +}; + +export interface MetricView { + key: MetricKey; + label: string; + value: string | null; + reason: string; +} + +@Component({ + selector: 'app-site-detail', + standalone: true, + imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart], + templateUrl: './site-detail.html', + styleUrl: './site-detail.scss', +}) +export class SiteDetail { + private route = inject(ActivatedRoute); + private sitesService = inject(SitesService); + private readingsService = inject(ReadingsService); + + siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? ''))); + + site = signal(null); + latestReading = signal(null); + history = signal([]); + error = signal(null); + + metrics = computed(() => { + const reading = this.latestReading(); + return METRIC_DEFS.map((def) => { + const valeur = reading ? reading[def.key] : null; + return { + key: def.key, + label: def.label, + value: valeur != null ? def.format(valeur) : null, + reason: valeur == null ? this.reasonFor(def.key, reading) : '', + }; + }); + }); + + constructor() { + effect(() => { + const siteId = this.siteId(); + if (siteId) { + this.load(siteId); + } + }); + } + + badgeToneForStatus(status: string | null): BadgeTone { + return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; + } + + private load(siteId: string): void { + this.sitesService + .getSite(siteId) + .pipe( + switchMap((site) => + this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), + ), + switchMap(({ site, latest }) => { + // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la + // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique + // vide dès que le jeu de données n'est plus récent. + const end = latest?.timestamp; + const start = end + ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() + : undefined; + return this.readingsService + .getHistory(siteId, start, end) + .pipe(map((history) => ({ site, latest, history }))); + }), + catchError(() => this.reportUnavailable()), + ) + .subscribe((result) => { + this.error.set(null); + this.site.set(result.site); + this.latestReading.set(result.latest); + this.history.set(result.history); + }); + } + + private reasonFor(field: MetricKey, reading: Reading | null): string { + const raisons = RAISONS_PAR_CHAMP[field]; + const trouvees = (reading?.null_reasons ?? []) + .filter((raison) => raisons.includes(raison)) + .map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison); + return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue'; + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss new file mode 100644 index 0000000..bfa4956 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 260px; +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts new file mode 100644 index 0000000..bb6232f --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -0,0 +1,80 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { Chart } from 'chart.js'; +import { ReadingHistoryChart } from './reading-history-chart'; + +vi.mock('chart.js', () => { + class ChartMock { + static instances: ChartMock[] = []; + static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } + } + return { Chart: ChartMock, registerables: [] }; +}); + +type ChartDouble = { destroy: ReturnType }; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + +const READING = { + reading_id: 1, + site_id: 'S1', + timestamp: '2026-09-17T10:00:00Z', + source: 'api_history' as const, + consumption_kw: 42, + consumption_kwh: null, + consumption_euros: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: null, + humidity_percent: null, + solar_irradiance_wm2: null, + is_working_hours: null, + data_quality: 'good' as const, + null_reasons: null, + imputed_values: null, + imputation_method: null, +}; + +describe('ReadingHistoryChart', () => { + it('se crée sans erreur avec une liste de lectures valide', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('met à jour le graphique quand les lectures changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + fixture.detectChanges(); + + fixture.componentRef.setInput('readings', [ + { ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const }, + ]); + fixture.detectChanges(); + + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + fixture.componentRef.setInput('readings', [READING]); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts new file mode 100644 index 0000000..922ca01 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -0,0 +1,80 @@ +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; +import { Chart, registerables } from 'chart.js'; +import { Reading, ReadingDataQuality } from '../../models/reading.model'; + +Chart.register(...registerables); + +const QUALITY_COLORS: Record = { + good: '#3b82f6', + partial: '#f9a825', + degraded: '#ef6c00', + critical: '#c62828', +}; +const UNKNOWN_QUALITY_COLOR = '#9ca3af'; + +function pointColors(readings: Reading[]): string[] { + return readings.map((r) => (r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR)); +} + +@Component({ + selector: 'app-reading-history-chart', + standalone: true, + templateUrl: './reading-history-chart.html', + styleUrl: './reading-history-chart.scss', +}) +export class ReadingHistoryChart implements AfterViewInit, OnDestroy { + readings = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart<'line'>; + + constructor() { + effect(() => { + const readings = this.readings(); + if (this.chart) { + this.chart.data.labels = readings.map((r) => r.timestamp); + this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0); + this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings); + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const readings = this.readings(); + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'line', + data: { + labels: readings.map((r) => r.timestamp), + datasets: [ + { + data: readings.map((r) => r.consumption_kw ?? 0), + borderColor: '#3b82f6', + pointBackgroundColor: pointColors(readings), + tension: 0.25, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } }, + }, + }, + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} diff --git a/apps/frontend/src/app/shared/models/reading.model.ts b/apps/frontend/src/app/shared/models/reading.model.ts new file mode 100644 index 0000000..daba555 --- /dev/null +++ b/apps/frontend/src/app/shared/models/reading.model.ts @@ -0,0 +1,23 @@ +export type ReadingSource = 'csv' | 'api_current' | 'api_history'; +export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical'; + +export interface Reading { + reading_id: number; + site_id: string; + timestamp: string; + source: ReadingSource; + consumption_kw: number | null; + consumption_kwh: number | null; + consumption_euros: string | null; + voltage_v: number | null; + current_a: number | null; + power_factor: number | null; + temperature_celsius: number | null; + humidity_percent: number | null; + solar_irradiance_wm2: number | null; + is_working_hours: boolean | null; + data_quality: ReadingDataQuality | null; + null_reasons: string[] | null; + imputed_values: Record | null; + imputation_method: string | null; +} From 41acdb54b65ee908203d6ebca020188a632cd341 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 16:43:40 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix(frontend):=20d=C3=A9sactive=20les=20fix?= =?UTF-8?q?tures=20mock,=20le=20backend=20est=20aliment=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dataset historique 2023-2024 est chargé en base. /stats/summary et /alerts servaient encore des fixtures déconnectées de ces données réelles. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012i5NteMLRZgfTAKB5GD37X --- apps/frontend/src/environments/environment.development.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/environments/environment.development.ts b/apps/frontend/src/environments/environment.development.ts index 8409d18..3c946c9 100644 --- a/apps/frontend/src/environments/environment.development.ts +++ b/apps/frontend/src/environments/environment.development.ts @@ -1,5 +1,5 @@ export const environment = { production: false, apiUrl: '/api/v1', - useMockFixtures: true, // a passer a false une fois le backend prêt + useMockFixtures: false, }; From e26948932ecf934cc372dc14352dedc06305d684 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 16:47:40 +0200 Subject: [PATCH 3/6] fix(frontend): annule le chargement en cours lors d'un changement rapide de site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans switchMap sur le flux externe, une réponse HTTP en retard pouvait écraser l'affichage du site actuellement sélectionné après une navigation rapide entre deux sites. --- .../features/sites/site-detail/site-detail.ts | 69 ++++++++++--------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index fa267d0..3b5deb1 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -1,7 +1,7 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; -import { catchError, EMPTY, map, Observable, switchMap } from 'rxjs'; +import { catchError, EMPTY, filter, map, Observable, switchMap } from 'rxjs'; import { SitesService } from '../../../core/services/sites.service'; import { ReadingsService } from '../../../core/services/readings.service'; import { Site } from '../../../shared/models/site.model'; @@ -83,6 +83,7 @@ export class SiteDetail { private route = inject(ActivatedRoute); private sitesService = inject(SitesService); private readingsService = inject(ReadingsService); + private destroyRef = inject(DestroyRef); siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? ''))); @@ -105,38 +106,13 @@ export class SiteDetail { }); constructor() { - effect(() => { - const siteId = this.siteId(); - if (siteId) { - this.load(siteId); - } - }); - } - - badgeToneForStatus(status: string | null): BadgeTone { - return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; - } - - private load(siteId: string): void { - this.sitesService - .getSite(siteId) + toObservable(this.siteId) .pipe( - switchMap((site) => - this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), - ), - switchMap(({ site, latest }) => { - // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la - // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique - // vide dès que le jeu de données n'est plus récent. - const end = latest?.timestamp; - const start = end - ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() - : undefined; - return this.readingsService - .getHistory(siteId, start, end) - .pipe(map((history) => ({ site, latest, history }))); - }), - catchError(() => this.reportUnavailable()), + filter((siteId): siteId is string => !!siteId), + // Piège : switchMap sur le flux externe annule le chargement en cours dès qu'un + // nouveau siteId arrive, sinon une réponse en retard peut écraser le site affiché. + switchMap((siteId) => this.load(siteId)), + takeUntilDestroyed(this.destroyRef), ) .subscribe((result) => { this.error.set(null); @@ -146,6 +122,31 @@ export class SiteDetail { }); } + badgeToneForStatus(status: string | null): BadgeTone { + return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; + } + + private load(siteId: string) { + return this.sitesService.getSite(siteId).pipe( + switchMap((site) => + this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), + ), + switchMap(({ site, latest }) => { + // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la + // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique + // vide dès que le jeu de données n'est plus récent. + const end = latest?.timestamp; + const start = end + ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() + : undefined; + return this.readingsService + .getHistory(siteId, start, end) + .pipe(map((history) => ({ site, latest, history }))); + }), + catchError(() => this.reportUnavailable()), + ); + } + private reasonFor(field: MetricKey, reading: Reading | null): string { const raisons = RAISONS_PAR_CHAMP[field]; const trouvees = (reading?.null_reasons ?? []) From 7db57f162cbb9435593e275859c03007454ed112 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 11:03:57 +0200 Subject: [PATCH 4/6] =?UTF-8?q?refactor(frontend):=20aligne=20la=20vue=20d?= =?UTF-8?q?=C3=A9tail=20sur=20le=20contrat=20SiteCurrentResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/sites/{site_id}/current` renvoie un objet toujours présent, sans `reading_id` ni `source`, avec `timestamp` nullable et `null_reasons` non nullable. La vue s'appuyait sur une réponse nulle pour détecter l'absence de mesure : elle s'appuie désormais sur `timestamp`, et affiche `data_quality`, que le contrat précédent ne portait pas. `getCurrent()` rejoint `SitesService`, l'endpoint appartenant à `/sites`. --- .../src/app/core/services/readings.service.ts | 4 - .../src/app/core/services/sites.service.ts | 5 ++ .../sites/site-detail/site-detail.html | 85 ++++++++++--------- .../features/sites/site-detail/site-detail.ts | 81 ++++++++++++------ .../app/shared/models/site-current.model.ts | 16 ++++ 5 files changed, 124 insertions(+), 67 deletions(-) create mode 100644 apps/frontend/src/app/shared/models/site-current.model.ts diff --git a/apps/frontend/src/app/core/services/readings.service.ts b/apps/frontend/src/app/core/services/readings.service.ts index d692237..00a2711 100644 --- a/apps/frontend/src/app/core/services/readings.service.ts +++ b/apps/frontend/src/app/core/services/readings.service.ts @@ -7,10 +7,6 @@ import { Reading } from '../../shared/models/reading.model'; export class ReadingsService { private http = inject(HttpClient); - getLatest(siteId: string) { - return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); - } - getHistory(siteId: string, start?: string, end?: string) { let params = new HttpParams().set('site_id', siteId); if (start) { diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts index 449bd29..c613c7c 100644 --- a/apps/frontend/src/app/core/services/sites.service.ts +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -2,6 +2,7 @@ import { Service, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../../environments/environment'; import { Site } from '../../shared/models/site.model'; +import { SiteCurrent } from '../../shared/models/site-current.model'; @Service() export class SitesService { @@ -14,4 +15,8 @@ export class SitesService { getSite(siteId: string) { return this.http.get(`${environment.apiUrl}/sites/${siteId}`); } + + getCurrent(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); + } } diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html index f2fbae9..e95ac75 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.html +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -17,9 +17,14 @@

} - @if (site(); as s) { - {{ s.status ?? '-' }} - } +
+ @if (site(); as s) { + {{ s.status ?? '-' }} + } + @if (hasMeasurement() && qualityLabel(); as label) { + {{ label }} + } +
@if (error(); as message) { @@ -27,43 +32,47 @@ } @if (site(); as s) { -
- - Consommation vs capacité - - - {{ latestReading()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW - - + @if (hasMeasurement()) { +
+ + Consommation vs capacité + + + {{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW + + - - Mesure instantanée -
- @for (metric of metrics(); track metric.key) { -
-
{{ metric.label }}
- @if (metric.value !== null) { -
{{ metric.value }}
- } @else { -
- Indisponible - ({{ metric.reason }}) -
- } -
- } -
-
-
- - @if (history().length > 0) { -
-

Historique de consommation

- + + Mesure instantanée +
+ @for (metric of metrics(); track metric.key) { +
+
{{ metric.label }}
+ @if (metric.value !== null) { +
{{ metric.value }}
+ } @else { +
+ Indisponible + ({{ metric.reason }}) +
+ } +
+ } +
+
+ + @if (history().length > 0) { +
+

Historique de consommation

+ +
+ } + } @else { + } } diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 3b5deb1..778ec8f 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -1,11 +1,12 @@ import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; -import { catchError, EMPTY, filter, map, Observable, switchMap } from 'rxjs'; +import { catchError, EMPTY, filter, map, Observable, of, switchMap } from 'rxjs'; import { SitesService } from '../../../core/services/sites.service'; import { ReadingsService } from '../../../core/services/readings.service'; import { Site } from '../../../shared/models/site.model'; -import { Reading } from '../../../shared/models/reading.model'; +import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model'; +import { SiteCurrent } from '../../../shared/models/site-current.model'; import { Card } from '../../../shared/components/ui/card/card'; import { Alert } from '../../../shared/components/ui/alert/alert'; import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge'; @@ -14,6 +15,7 @@ import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/c import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart'; const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.'; +const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.'; const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000; const TON_PAR_STATUT: Record = { @@ -22,6 +24,20 @@ const TON_PAR_STATUT: Record = { hors_service: 'danger', }; +const TON_PAR_QUALITE: Record = { + good: 'success', + partial: 'warning', + degraded: 'danger', + critical: 'critical', +}; + +const LIBELLE_PAR_QUALITE: Record = { + good: 'Données complètes', + partial: 'Données partielles', + degraded: 'Données dégradées', + critical: 'Données critiques', +}; + type MetricKey = | 'consumption_kw' | 'voltage_v' @@ -45,9 +61,8 @@ const METRIC_DEFS: MetricDef[] = [ { key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` }, ]; -// Contrainte : miroir de `RAISON_VERS_CAPTEUR`/`CHAMPS_PAR_CAPTEUR` côté backend -// (apps/backend/app/services/sensor.py) - `null_reasons` porte le code de panne du capteur, -// jamais le nom du champ. +// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ; +// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide. const RAISONS_PAR_CHAMP: Record = { consumption_kw: ['consumption_sensor_failure', 'network_loss'], voltage_v: ['electrical_sensor_failure', 'network_loss'], @@ -85,22 +100,36 @@ export class SiteDetail { private readingsService = inject(ReadingsService); private destroyRef = inject(DestroyRef); + readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE; + siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? ''))); site = signal(null); - latestReading = signal(null); + current = signal(null); history = signal([]); error = signal(null); + hasMeasurement = computed(() => this.current()?.timestamp != null); + + qualityLabel = computed(() => { + const quality = this.current()?.data_quality; + return quality ? LIBELLE_PAR_QUALITE[quality] : null; + }); + + qualityTone = computed(() => { + const quality = this.current()?.data_quality; + return quality ? TON_PAR_QUALITE[quality] : 'neutral'; + }); + metrics = computed(() => { - const reading = this.latestReading(); + const current = this.current(); return METRIC_DEFS.map((def) => { - const valeur = reading ? reading[def.key] : null; + const valeur = current ? current[def.key] : null; return { key: def.key, label: def.label, value: valeur != null ? def.format(valeur) : null, - reason: valeur == null ? this.reasonFor(def.key, reading) : '', + reason: valeur == null ? this.reasonFor(def.key, current) : '', }; }); }); @@ -117,7 +146,7 @@ export class SiteDetail { .subscribe((result) => { this.error.set(null); this.site.set(result.site); - this.latestReading.set(result.latest); + this.current.set(result.current); this.history.set(result.history); }); } @@ -129,27 +158,29 @@ export class SiteDetail { private load(siteId: string) { return this.sitesService.getSite(siteId).pipe( switchMap((site) => - this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), + this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))), + ), + switchMap(({ site, current }) => + this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))), ), - switchMap(({ site, latest }) => { - // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la - // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique - // vide dès que le jeu de données n'est plus récent. - const end = latest?.timestamp; - const start = end - ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() - : undefined; - return this.readingsService - .getHistory(siteId, start, end) - .pipe(map((history) => ({ site, latest, history }))); - }), catchError(() => this.reportUnavailable()), ); } - private reasonFor(field: MetricKey, reading: Reading | null): string { + private loadHistory(siteId: string, current: SiteCurrent): Observable { + // Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la + // dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide. + const end = current.timestamp; + if (end === null) { + return of([]); + } + const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString(); + return this.readingsService.getHistory(siteId, start, end); + } + + private reasonFor(field: MetricKey, current: SiteCurrent | null): string { const raisons = RAISONS_PAR_CHAMP[field]; - const trouvees = (reading?.null_reasons ?? []) + const trouvees = (current?.null_reasons ?? []) .filter((raison) => raisons.includes(raison)) .map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison); return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue'; diff --git a/apps/frontend/src/app/shared/models/site-current.model.ts b/apps/frontend/src/app/shared/models/site-current.model.ts new file mode 100644 index 0000000..9f1e6f5 --- /dev/null +++ b/apps/frontend/src/app/shared/models/site-current.model.ts @@ -0,0 +1,16 @@ +import { ReadingDataQuality } from './reading.model'; + +export interface SiteCurrent { + timestamp: string | null; + site_id: string; + site_type: string; + consumption_kw: number | null; + consumption_kwh: number | null; + voltage_v: number | null; + current_a: number | null; + power_factor: number | null; + temperature_celsius: number | null; + humidity_percent: number | null; + null_reasons: string[]; + data_quality: ReadingDataQuality; +} From 9d3e402ca49c42213cefa16e38f93ca474444c9f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 11:11:12 +0200 Subject: [PATCH 5/6] =?UTF-8?q?test(frontend):=20couvre=20la=20vue=20d?= =?UTF-8?q?=C3=A9tail=20sur=20le=20contrat=20SiteCurrentResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le cas « site sans mesure » ne passe plus par une réponse nulle mais par `timestamp` à null : il ne doit alors pas interroger `/readings`, et la vue annonce l'absence de mesure au lieu de six métriques de cause inconnue. --- .../core/services/readings.service.spec.ts | 30 +----- .../app/core/services/sites.service.spec.ts | 27 +++++ .../sites/site-detail/site-detail.scss | 12 +++ .../sites/site-detail/site-detail.spec.ts | 101 ++++++++++++------ .../features/sites/site-detail/site-detail.ts | 2 +- 5 files changed, 110 insertions(+), 62 deletions(-) diff --git a/apps/frontend/src/app/core/services/readings.service.spec.ts b/apps/frontend/src/app/core/services/readings.service.spec.ts index 98a3177..2513f08 100644 --- a/apps/frontend/src/app/core/services/readings.service.spec.ts +++ b/apps/frontend/src/app/core/services/readings.service.spec.ts @@ -18,33 +18,11 @@ describe('ReadingsService', () => { afterEach(() => httpMock.verify()); - it("demande la dernière lecture connue du site, quel que soit son âge", () => { - let result: unknown; - service.getLatest('SITE001').subscribe((r) => (result = r)); - - const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); - expect(req.request.method).toBe('GET'); - - req.flush({ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }); - - expect((result as { reading_id: number }).reading_id).toBe(1); - }); - - it("retourne null quand le site n'a aucune lecture", () => { - let result: unknown; - service.getLatest('SITE001').subscribe((r) => (result = r)); - - const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); - req.flush(null); - - expect(result).toBeNull(); - }); - it("demande l'historique du site avec la fenêtre temporelle donnée", () => { let result: unknown; - service.getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z').subscribe( - (r) => (result = r), - ); + service + .getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z') + .subscribe((r) => (result = r)); const req = httpMock.expectOne( (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', @@ -58,7 +36,7 @@ describe('ReadingsService', () => { expect((result as unknown[]).length).toBe(1); }); - it("ne pose pas de paramètres start/end quand ils sont omis", () => { + it('ne pose pas de paramètres start/end quand ils sont omis', () => { service.getHistory('SITE001').subscribe(); const req = httpMock.expectOne( diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts index 8eabeb2..7f475e7 100644 --- a/apps/frontend/src/app/core/services/sites.service.spec.ts +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -57,4 +57,31 @@ describe('SitesService', () => { expect((result as { site_id: string }).site_id).toBe('SITE001'); }); + + it('appelle le bon endpoint et retourne la mesure courante du site', () => { + let result: unknown; + service.getCurrent('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-17T10:00:00Z', + site_id: 'SITE001', + site_type: 'industriel', + consumption_kw: 120, + consumption_kwh: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: 22, + humidity_percent: 55, + null_reasons: ['electrical_sensor_failure'], + data_quality: 'partial', + }); + + expect((result as { null_reasons: string[] }).null_reasons).toEqual([ + 'electrical_sensor_failure', + ]); + }); }); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index fbb17c6..78dd9ed 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -28,11 +28,23 @@ color: var(--color-text-muted); } +.site-detail__badges { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + .banner-error { display: block; margin: 0 0 1.5rem; } +.banner-empty { + display: block; + margin: 0 0 1.5rem; +} + .overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 11e6c8f..3fdc269 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -15,12 +15,39 @@ const SITE = { status: 'actif', }; -const READING_COMPLETE = { +const CURRENT_COMPLET = { + timestamp: '2026-09-17T10:00:00Z', + site_id: 'SITE001', + site_type: 'industriel', + consumption_kw: 120, + consumption_kwh: null, + voltage_v: 230, + current_a: 12, + power_factor: 0.95, + temperature_celsius: 22, + humidity_percent: 55, + null_reasons: [] as string[], + data_quality: 'good' as const, +}; + +const SANS_MESURE = { + ...CURRENT_COMPLET, + timestamp: null, + consumption_kw: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: null, + humidity_percent: null, + data_quality: 'critical' as const, +}; + +const LECTURE = { reading_id: 1, site_id: 'SITE001', - timestamp: '2026-09-17T10:00:00Z', - source: 'api_current' as const, - consumption_kw: 120, + timestamp: '2026-09-17T09:00:00Z', + source: 'api_history' as const, + consumption_kw: 118, consumption_kwh: null, consumption_euros: null, voltage_v: 230, @@ -55,20 +82,20 @@ function setup( } describe('SiteDetail', () => { - it('charge le site, la dernière lecture et son historique au démarrage', () => { + it('charge le site, la mesure courante et son historique au démarrage', () => { const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([READING_COMPLETE])), + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, ); fixture.detectChanges(); expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); - expect(fixture.componentInstance.latestReading()?.consumption_kw).toBe(120); + expect(fixture.componentInstance.current()?.consumption_kw).toBe(120); expect(fixture.componentInstance.history().length).toBe(1); expect(fixture.componentInstance.error()).toBeNull(); }); @@ -76,11 +103,11 @@ describe('SiteDetail', () => { it("signale l'indisponibilité quand un des appels échoue", () => { const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([])), + getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), }, + { getHistory: vi.fn().mockReturnValue(of([])) }, ); fixture.detectChanges(); @@ -90,44 +117,41 @@ describe('SiteDetail', () => { }); it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { - const readingPartielle = { - ...READING_COMPLETE, + const partielle = { + ...CURRENT_COMPLET, voltage_v: null, current_a: null, power_factor: null, null_reasons: ['electrical_sensor_failure'], + data_quality: 'partial' as const, }; const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, { - getLatest: vi.fn().mockReturnValue(of(readingPartielle)), - getHistory: vi.fn().mockReturnValue(of([readingPartielle])), + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(partielle)), }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, ); fixture.detectChanges(); - const tension = fixture.componentInstance - .metrics() - .find((m) => m.key === 'voltage_v'); + const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v'); expect(tension?.value).toBeNull(); expect(tension?.reason).toBe('capteur électrique en panne'); - const html = fixture.nativeElement.textContent; - expect(html).toContain('Indisponible'); - expect(html).toContain('capteur électrique en panne'); + const texte = fixture.nativeElement.textContent; + expect(texte).toContain('Indisponible'); + expect(texte).toContain('capteur électrique en panne'); + expect(texte).toContain('Données partielles'); }); it('recharge les données quand le paramètre de route siteId change', () => { const getSite = vi.fn().mockReturnValue(of(SITE)); const { fixture, paramMap } = setup( 'SITE001', - { getSite }, - { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([])), - }, + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([])) }, ); fixture.detectChanges(); @@ -141,8 +165,11 @@ describe('SiteDetail', () => { const getHistory = vi.fn().mockReturnValue(of([])); const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, - { getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), getHistory }, + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory }, ); fixture.detectChanges(); @@ -154,17 +181,21 @@ describe('SiteDetail', () => { ); }); - it("ne fixe aucune fenêtre d'historique quand le site n'a aucune lecture", () => { + it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => { const getHistory = vi.fn().mockReturnValue(of([])); const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, - { getLatest: vi.fn().mockReturnValue(of(null)), getHistory }, + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)), + }, + { getHistory }, ); fixture.detectChanges(); - expect(getHistory).toHaveBeenCalledWith('SITE001', undefined, undefined); - expect(fixture.componentInstance.latestReading()).toBeNull(); + expect(getHistory).not.toHaveBeenCalled(); + expect(fixture.componentInstance.hasMeasurement()).toBe(false); + expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.'); }); }); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 778ec8f..047614a 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -76,7 +76,7 @@ const LIBELLE_PAR_RAISON: Record = { consumption_sensor_failure: 'capteur de consommation en panne', electrical_sensor_failure: 'capteur électrique en panne', temperature_sensor_failure: 'capteur de température en panne', - humidity_sensor_failure: 'capteur d\'humidité en panne', + humidity_sensor_failure: "capteur d'humidité en panne", network_loss: 'perte réseau', }; From 33807e3038356329b0e6a9751534e465864cdb0a Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 14:11:44 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(frontend):=20traite=20la=20revue=20de?= =?UTF-8?q?=20phyri0s=20sur=20la=20vue=20d=C3=A9tail=20d'un=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre points portant sur le code de cette PR : - L'échec de chargement laissait à l'écran le site précédemment affiché sous le bandeau d'erreur : `reportUnavailable()` vide désormais site, mesure et historique, pour qu'on ne lise pas les chiffres de A en croyant regarder B. - L'historique était tracé à rebours : l'API trie en timestamp décroissant (`ReadingRepository.list_history`), le graphique rétablit la chronologie. - Une consommation `null` (panne capteur) alimentait la jauge avec un 0, indiscernable d'un site qui ne consomme rien : la jauge n'est plus montée dans ce cas, la raison de l'absence est affichée à la place. Une consommation réellement mesurée à 0 continue d'afficher la jauge. - `getSite` et `getCurrent` ne dépendent pas l'un de l'autre : `forkJoin` économise un aller-retour en série à chaque ouverture de la page. Co-Authored-By: Claude Opus 5 (1M context) --- .../sites/site-detail/site-detail.html | 19 +++-- .../sites/site-detail/site-detail.scss | 5 ++ .../sites/site-detail/site-detail.spec.ts | 80 +++++++++++++++++++ .../features/sites/site-detail/site-detail.ts | 32 ++++++-- .../reading-history-chart.spec.ts | 27 ++++++- .../reading-history-chart.ts | 35 +++++--- 6 files changed, 172 insertions(+), 26 deletions(-) diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html index e95ac75..9c4a4bc 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.html +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -36,13 +36,18 @@
Consommation vs capacité - - - {{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW - + @let consumption = consumptionKw(); + @if (consumption !== null) { + + + {{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW + + } @else { +

+ Consommation indisponible + ({{ consumptionReason() }}) +

+ }
diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index 78dd9ed..a0032be 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -74,6 +74,11 @@ font-weight: 700; } +.card__unavailable { + color: var(--color-text-muted); + margin: 0; +} + .metrics-grid { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 3fdc269..5e3cf46 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -116,6 +116,86 @@ describe('SiteDetail', () => { expect(fixture.componentInstance.site()).toBeNull(); }); + it('efface les données du site précédent quand le chargement du suivant échoue', () => { + const getSite = vi + .fn() + .mockReturnValueOnce(of(SITE)) + .mockReturnValueOnce(throwError(() => new Error('404'))); + const { fixture, paramMap } = setup( + 'SITE001', + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + + paramMap.next(convertToParamMap({ siteId: 'SITE002' })); + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.site()).toBeNull(); + expect(fixture.componentInstance.current()).toBeNull(); + expect(fixture.componentInstance.history()).toEqual([]); + expect(fixture.nativeElement.textContent).not.toContain('Site 1'); + }); + + it('interroge le site et sa mesure courante en parallèle', () => { + const getSite = vi.fn().mockReturnValue(of(SITE)); + const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET)); + const { fixture } = setup( + 'SITE001', + { getSite, getCurrent }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + + expect(getSite).toHaveBeenCalledWith('SITE001'); + expect(getCurrent).toHaveBeenCalledWith('SITE001'); + }); + + it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => { + const sansConsommation = { + ...CURRENT_COMPLET, + consumption_kw: null, + null_reasons: ['consumption_sensor_failure'], + data_quality: 'partial' as const, + }; + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(sansConsommation)), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.consumptionKw()).toBeNull(); + expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne'); + expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Consommation indisponible'); + }); + + it('trace la jauge pour une consommation nulle réellement mesurée', () => { + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW'); + expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible'); + }); + it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { const partielle = { ...CURRENT_COMPLET, diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 047614a..21e716d 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -1,7 +1,7 @@ import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; -import { catchError, EMPTY, filter, map, Observable, of, switchMap } from 'rxjs'; +import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs'; import { SitesService } from '../../../core/services/sites.service'; import { ReadingsService } from '../../../core/services/readings.service'; import { Site } from '../../../shared/models/site.model'; @@ -52,8 +52,14 @@ interface MetricDef { format: (value: number) => string; } +const CONSUMPTION_DEF: MetricDef = { + key: 'consumption_kw', + label: 'Consommation', + format: (v) => `${v.toFixed(1)} kW`, +}; + const METRIC_DEFS: MetricDef[] = [ - { key: 'consumption_kw', label: 'Consommation', format: (v) => `${v.toFixed(1)} kW` }, + CONSUMPTION_DEF, { key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` }, { key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` }, { key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) }, @@ -111,6 +117,15 @@ export class SiteDetail { hasMeasurement = computed(() => this.current()?.timestamp != null); + consumptionKw = computed(() => this.current()?.consumption_kw ?? null); + + consumptionLabel = computed(() => { + const kw = this.consumptionKw(); + return kw != null ? CONSUMPTION_DEF.format(kw) : null; + }); + + consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current())); + qualityLabel = computed(() => { const quality = this.current()?.data_quality; return quality ? LIBELLE_PAR_QUALITE[quality] : null; @@ -156,10 +171,10 @@ export class SiteDetail { } private load(siteId: string) { - return this.sitesService.getSite(siteId).pipe( - switchMap((site) => - this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))), - ), + return forkJoin({ + site: this.sitesService.getSite(siteId), + current: this.sitesService.getCurrent(siteId), + }).pipe( switchMap(({ site, current }) => this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))), ), @@ -186,8 +201,13 @@ export class SiteDetail { return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue'; } + // Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé + // sous le bandeau et laisse lire les chiffres de A en croyant regarder B. private reportUnavailable(): Observable { this.error.set(UNAVAILABLE_MESSAGE); + this.site.set(null); + this.current.set(null); + this.history.set([]); return EMPTY; } } diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts index bb6232f..63be883 100644 --- a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -9,15 +9,21 @@ vi.mock('chart.js', () => { static register = vi.fn(); update = vi.fn(); destroy = vi.fn(); - data = { datasets: [{}] }; - constructor() { + data: { labels?: unknown[]; datasets: Record[] } = { datasets: [{}] }; + constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) { + if (config?.data) { + this.data = config.data; + } ChartMock.instances.push(this); } } return { Chart: ChartMock, registerables: [] }; }); -type ChartDouble = { destroy: ReturnType }; +type ChartDouble = { + destroy: ReturnType; + data: { labels?: unknown[]; datasets: Record[] }; +}; function lastChart(): ChartDouble | undefined { return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); @@ -66,6 +72,21 @@ describe('ReadingHistoryChart', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + // L'API trie en timestamp décroissant : le composant doit rétablir la chronologie. + fixture.componentRef.setInput('readings', [ + { ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 }, + { ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 }, + ]); + fixture.detectChanges(); + + const chart = lastChart(); + expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']); + expect(chart?.data.datasets[0]['data']).toEqual([42, 60]); + }); + it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); const fixture = TestBed.createComponent(ReadingHistoryChart); diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts index 922ca01..17d1e06 100644 --- a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -20,8 +20,23 @@ const QUALITY_COLORS: Record = { }; const UNKNOWN_QUALITY_COLOR = '#9ca3af'; -function pointColors(readings: Reading[]): string[] { - return readings.map((r) => (r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR)); +interface ChartSeries { + labels: string[]; + values: number[]; + colors: string[]; +} + +// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history +// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours. +function toSeries(readings: Reading[]): ChartSeries { + const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + return { + labels: ordered.map((r) => r.timestamp), + values: ordered.map((r) => r.consumption_kw ?? 0), + colors: ordered.map((r) => + r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR, + ), + }; } @Component({ @@ -38,27 +53,27 @@ export class ReadingHistoryChart implements AfterViewInit, OnDestroy { constructor() { effect(() => { - const readings = this.readings(); + const series = toSeries(this.readings()); if (this.chart) { - this.chart.data.labels = readings.map((r) => r.timestamp); - this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0); - this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings); + this.chart.data.labels = series.labels; + this.chart.data.datasets[0].data = series.values; + this.chart.data.datasets[0].pointBackgroundColor = series.colors; this.chart.update('none'); } }); } ngAfterViewInit(): void { - const readings = this.readings(); + const series = toSeries(this.readings()); this.chart = new Chart(this.canvasRef.nativeElement, { type: 'line', data: { - labels: readings.map((r) => r.timestamp), + labels: series.labels, datasets: [ { - data: readings.map((r) => r.consumption_kw ?? 0), + data: series.values, borderColor: '#3b82f6', - pointBackgroundColor: pointColors(readings), + pointBackgroundColor: series.colors, tension: 0.25, }, ],