Initiale Version: Stromverbrauch-Web-App (strom-next)

Next.js-App zur Visualisierung des Stromverbrauchs aus InfluxDB
(Bucket strom, measurement vzlogger, Feld arbeit = kumulativer
Zaehlerstand in Wh).

- 4 Balken-Diagramme: 24h (stuendlich), Woche (stuendlich),
  31 Tage (taeglich), Jahr (pro Monat, immer 12 Monate)
- Verbrauch via difference des Zaehlerstands, zeitzonen-korrekt
- 24h liest Rohdaten (aktuell), 7d/31d/365d den stuendlichen
  Downsampling-Rollup (arbeit_hourly) -> sub-sekunde
- Gesamtverbrauch je Zeitraum unter dem Chart
- Design/Layout angelehnt an Werte-Log "Verlauf"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:05:27 +02:00
commit 3649bf4e22
19 changed files with 7659 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
import { InfluxDB } from '@influxdata/influxdb-client';
import { ResolvedRange, windowStartBefore } from '@/lib/ranges';
import { VerbrauchPoint } from '@/types/strom';
const URL = process.env.INFLUX_URL || 'http://nuccy:8086';
const TOKEN = process.env.INFLUX_TOKEN || '';
const ORG = process.env.INFLUX_ORG || 'citysensor';
const BUCKET = process.env.INFLUX_BUCKET || 'strom';
const FIELD = process.env.INFLUX_FIELD || 'arbeit';
// Rohdaten (1-Sekunden-Zaehlerstand) — fuer 24h, immer aktuell
const MEAS_RAW = process.env.INFLUX_MEASUREMENT || 'vzlogger';
// Stuendliche Snapshots (Downsampling-Task) — fuer 7d/31d/365d, schnell
const MEAS_ROLLUP = process.env.INFLUX_ROLLUP_MEASUREMENT || 'arbeit_hourly';
const UNIT_FACTOR = parseFloat(process.env.INFLUX_UNIT_FACTOR || '0.001'); // *Faktor -> kWh (Wh)
const TZ = process.env.INFLUX_TZ || 'Europe/Berlin';
const influx = new InfluxDB({ url: URL, token: TOKEN, timeout: 90_000 });
/**
* Liefert den Verbrauch je Fenster aus dem kumulativen Zaehlerstand.
*
* Quelle 'raw' (24h): hoechstaufgeloeste Daten, aggregateWindow(last) holt den
* Zaehlerstand am Fensterende, difference() bildet den Zuwachs = Verbrauch.
*
* Quelle 'rollup' (7d/31d/365d): stuendliche Snapshots des Zaehlerstands.
* difference() ergibt den stuendlichen Verbrauch, der dann tages-/stundenweise
* summiert wird. Tagesgrenzen sind ueber `location` zeitzonen-korrekt.
*
* timeShift(-every) verschiebt den Zeitstempel auf den Fenster-ANFANG, sodass ein
* Balken den Zeitraum [T, T+every) repraesentiert. Die Abfrage startet ein Fenster
* frueher, damit das erste sichtbare Fenster bereits einen Differenzwert hat.
*/
export async function fetchVerbrauch(r: ResolvedRange): Promise<VerbrauchPoint[]> {
const queryStart = windowStartBefore(r.start, r.every); // ein Fenster Vorlauf
const measurement = r.source === 'raw' ? MEAS_RAW : MEAS_ROLLUP;
const aggregation =
r.source === 'raw'
? // Rohdaten: letzter Zaehlerstand je Fenster, dann Differenz
`|> aggregateWindow(every: ${r.every}, fn: last, createEmpty: false)
|> difference(nonNegative: true)`
: // Rollup: stuendliche Differenz, dann je Fenster summieren
`|> difference(nonNegative: true)
|> aggregateWindow(every: ${r.every}, fn: sum, createEmpty: false)`;
const flux = `
import "timezone"
option location = timezone.location(name: "${TZ}")
from(bucket: "${BUCKET}")
|> range(start: ${queryStart.toISOString()}, stop: ${r.stop.toISOString()})
|> filter(fn: (r) => r._measurement == "${measurement}")
|> filter(fn: (r) => r._field == "${FIELD}")
${aggregation}
|> timeShift(duration: -${r.every})
|> sort(columns: ["_time"], desc: false)
|> keep(columns: ["_time", "_value"])
`;
const queryApi = influx.getQueryApi(ORG);
const points: VerbrauchPoint[] = [];
const minTs = r.start.getTime();
const maxTs = r.stop.getTime();
return new Promise<VerbrauchPoint[]>((resolve, reject) => {
queryApi.queryRows(flux, {
next(row, tableMeta) {
const o = tableMeta.toObject(row);
const ts = new Date(o._time as string).getTime();
if (ts < minTs || ts >= maxTs) return; // Vorlauf-/Ueberhangfenster verwerfen
const value = Number(o._value);
if (!Number.isFinite(value)) return;
points.push([ts, value * UNIT_FACTOR]);
},
error(err) {
reject(err);
},
complete() {
resolve(points);
},
});
});
}
+92
View File
@@ -0,0 +1,92 @@
import { RangeKey, Every } from '@/types/strom';
// 'raw' = 1-Sekunden-Rohdaten (measurement vzlogger) — aktuell, fuer 24h
// 'rollup' = stuendliche Snapshots (measurement arbeit_hourly) — schnell, fuer 7d/31d/365d
export type Source = 'raw' | 'rollup';
export interface ResolvedRange {
start: Date; // Beginn des sichtbaren Zeitraums (inklusiv)
stop: Date; // Ende des sichtbaren Zeitraums (exklusiv)
every: Every; // Fensterbreite je Balken
source: Source;
}
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
/**
* Berechnet start/stop/every fuer ein Diagramm aus den Query-Parametern.
*
* - 24h: ohne date -> rollierend die letzten 24h ab jetzt (1 Balken/Stunde)
* mit date -> dieser Kalendertag 00:00-24:00
* - 7d: 7 Tage endend am gewaehlten Tag (Default heute), 1 Balken/Stunde
* - 31d: kompletter gewaehlter Monat (Default aktueller Monat), 1 Balken/Tag
* - 365d: komplettes gewaehltes Jahr (Default aktuelles Jahr), 1 Balken/Tag
*/
export function resolveRange(
range: RangeKey,
params: { date?: string | null; month?: string | null; year?: string | null }
): ResolvedRange {
const now = new Date();
switch (range) {
case '24h': {
if (params.date) {
const start = startOfDay(params.date);
return { start, stop: new Date(start.getTime() + DAY_MS), every: '1h', source: 'raw' };
}
// rollierend: letzte 24h ab jetzt (Rohdaten = aktuellster Wert)
return { start: new Date(now.getTime() - 24 * HOUR_MS), stop: now, every: '1h', source: 'raw' };
}
case '7d': {
// Ende = Ende des gewaehlten Tages (Default heute)
const day = params.date ? startOfDay(params.date) : startOfDay(toDateString(now));
const stop = new Date(day.getTime() + DAY_MS);
return { start: new Date(stop.getTime() - 7 * DAY_MS), stop, every: '1h', source: 'rollup' };
}
case '31d': {
const { year, month } = params.month
? parseMonth(params.month)
: { year: now.getFullYear(), month: now.getMonth() };
const start = new Date(year, month, 1);
const stop = new Date(year, month + 1, 1);
return { start, stop, every: '1d', source: 'rollup' };
}
case '365d': {
// ein Balken pro Monat
const year = params.year ? parseInt(params.year, 10) : now.getFullYear();
const start = new Date(year, 0, 1);
const stop = new Date(year + 1, 0, 1);
return { start, stop, every: '1mo', source: 'rollup' };
}
}
}
function startOfDay(dateStr: string): Date {
const [y, m, d] = dateStr.split('-').map(Number);
return new Date(y, m - 1, d);
}
function parseMonth(monthStr: string): { year: number; month: number } {
const [y, m] = monthStr.split('-').map(Number);
return { year: y, month: m - 1 };
}
function toDateString(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// Ein Fenster vor `d` (fuer den difference-Vorlauf). Monate kalendergenau.
export function windowStartBefore(d: Date, every: Every): Date {
const r = new Date(d);
if (every === '1h') r.setTime(r.getTime() - HOUR_MS);
else if (every === '1d') r.setTime(r.getTime() - DAY_MS);
else r.setMonth(r.getMonth() - 1); // '1mo'
return r;
}