fix(frontend): traite la revue de phyri0s sur la vue détail d'un site

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) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-09-18 14:11:44 +02:00
co-authored by Claude Opus 5
parent 9d3e402ca4
commit 33807e3038
6 changed files with 172 additions and 26 deletions
@@ -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<string, unknown>[] } = { 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<typeof vi.fn> };
type ChartDouble = {
destroy: ReturnType<typeof vi.fn>;
data: { labels?: unknown[]; datasets: Record<string, unknown>[] };
};
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);
@@ -20,8 +20,23 @@ const QUALITY_COLORS: Record<ReadingDataQuality, string> = {
};
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,
},
],