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 {
+ {{ noMeasurementMessage }}
}
}
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;
+}