From 7db57f162cbb9435593e275859c03007454ed112 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 11:03:57 +0200 Subject: [PATCH] =?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; +}