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:
@@ -0,0 +1,13 @@
|
|||||||
|
# InfluxDB-Verbindung
|
||||||
|
INFLUX_URL=http://nuccy:8086
|
||||||
|
INFLUX_TOKEN=dein-influx-token
|
||||||
|
INFLUX_ORG=citysensor
|
||||||
|
INFLUX_BUCKET=strom
|
||||||
|
# Rohdaten (1-Sekunden-Zaehlerstand) fuer 24h
|
||||||
|
INFLUX_MEASUREMENT=vzlogger
|
||||||
|
# Stuendliche Snapshots (Downsampling-Task) fuer 7d/31d/365d
|
||||||
|
INFLUX_ROLLUP_MEASUREMENT=arbeit_hourly
|
||||||
|
# Feldname (kumulativer Zaehlerstand)
|
||||||
|
INFLUX_FIELD=arbeit
|
||||||
|
# Umrechnung auf kWh: Zaehlerstand liegt in Wh vor -> 0.001
|
||||||
|
INFLUX_UNIT_FACTOR=0.001
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Stromverbrauch (strom-next)
|
||||||
|
|
||||||
|
Next.js-App zur Visualisierung des Stromverbrauchs aus einer InfluxDB.
|
||||||
|
Design/Layout angelehnt an das Projekt *Werte-Log* (Ansicht „Verlauf").
|
||||||
|
|
||||||
|
## Datenquelle
|
||||||
|
|
||||||
|
- InfluxDB Bucket `strom`, Measurement `vzlogger`, Feld `arbeit` (Zählerstand in **Wh**)
|
||||||
|
- Der Wert ist ein **kumulativer Zählerstand**; der Verbrauch je Intervall
|
||||||
|
ergibt sich aus der Differenz aufeinanderfolgender Zählerstände
|
||||||
|
(`difference` in Flux).
|
||||||
|
- Die Rohdaten haben 1-Sekunden-Auflösung. Für die langen Zeiträume wäre die
|
||||||
|
Live-Aggregation zu langsam (Jahr ≈ 70 s), daher gibt es einen **Downsampling-Task**.
|
||||||
|
|
||||||
|
## Downsampling-Task (InfluxDB)
|
||||||
|
|
||||||
|
Ein InfluxDB-Task `arbeit_hourly_rollup` (stündlich, in der InfluxDB unter
|
||||||
|
Tasks zu finden) schreibt fortlaufend den **letzten Zählerstand je Stunde** als
|
||||||
|
Measurement `arbeit_hourly` (Feld `arbeit`) zurück in den Bucket `strom`:
|
||||||
|
|
||||||
|
```flux
|
||||||
|
option task = {name: "arbeit_hourly_rollup", every: 1h, offset: 2m}
|
||||||
|
|
||||||
|
from(bucket: "strom")
|
||||||
|
|> range(start: -3h)
|
||||||
|
|> filter(fn: (r) => r._measurement == "vzlogger" and r._field == "arbeit")
|
||||||
|
|> aggregateWindow(every: 1h, fn: last, createEmpty: false)
|
||||||
|
|> set(key: "_measurement", value: "arbeit_hourly")
|
||||||
|
|> to(bucket: "strom")
|
||||||
|
```
|
||||||
|
|
||||||
|
- **24h**-Chart liest die **Rohdaten** (`vzlogger`) → immer top-aktuell.
|
||||||
|
- **7d/31d/365d** lesen den **Rollup** (`arbeit_hourly`) → sub-sekunden-schnell.
|
||||||
|
- Tages-/Stundenwerte: erst `difference` (stündlicher Verbrauch), dann
|
||||||
|
`aggregateWindow(sum)` pro Tag/Stunde (zeitzonen-korrekt via `Europe/Berlin`).
|
||||||
|
Liefert exakt dieselben Werte wie die Rohdaten-Aggregation.
|
||||||
|
|
||||||
|
### Backfill (einmalig, schon ausgeführt)
|
||||||
|
|
||||||
|
Die Historie ab 2022-10-09 wurde einmalig in `arbeit_hourly` geschrieben.
|
||||||
|
Falls erneut nötig (z. B. nach Datenlücke), jahresweise:
|
||||||
|
|
||||||
|
```flux
|
||||||
|
from(bucket:"strom")
|
||||||
|
|> range(start: 2025-01-01T00:00:00Z, stop: 2026-01-01T00:00:00Z)
|
||||||
|
|> filter(fn:(r)=>r._measurement=="vzlogger" and r._field=="arbeit")
|
||||||
|
|> aggregateWindow(every:1h, fn:last, createEmpty:false)
|
||||||
|
|> set(key:"_measurement", value:"arbeit_hourly")
|
||||||
|
|> to(bucket:"strom")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diagramme (alle als Balken-Diagramme)
|
||||||
|
|
||||||
|
| Zeitraum | Balken | Auswahl |
|
||||||
|
|-------------------|--------------|----------------------------------|
|
||||||
|
| Letzte 24 Stunden | 1 / Stunde | Tag (leer = letzte 24h ab jetzt) |
|
||||||
|
| Letzte 7 Tage | 1 / Stunde | Tag |
|
||||||
|
| Letzte 31 Tage | 1 / Tag | Monat |
|
||||||
|
| Letzte 365 Tage | 1 / Tag | Jahr |
|
||||||
|
|
||||||
|
X-Achse: Datum/Zeit · Y-Achse: kWh
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
# .env.local mit echtem INFLUX_TOKEN (und ggf. Feldname/Einheit) befüllen
|
||||||
|
```
|
||||||
|
|
||||||
|
### .env.local
|
||||||
|
|
||||||
|
```
|
||||||
|
INFLUX_URL=http://nuccy:8086
|
||||||
|
INFLUX_TOKEN=...
|
||||||
|
INFLUX_ORG=citysensor
|
||||||
|
INFLUX_BUCKET=strom
|
||||||
|
INFLUX_MEASUREMENT=vzlogger
|
||||||
|
INFLUX_FIELD=arbeit
|
||||||
|
INFLUX_UNIT_FACTOR=0.001 # Zählerstand liegt in Wh vor
|
||||||
|
```
|
||||||
|
|
||||||
|
Den Feldnamen und die Einheit ggf. einmalig prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -XPOST "http://nuccy:8086/api/v2/query?org=citysensor" \
|
||||||
|
-H "Authorization: Token $INFLUX_TOKEN" \
|
||||||
|
-H "Accept: application/csv" \
|
||||||
|
-H "Content-type: application/vnd.flux" \
|
||||||
|
-d 'import "influxdata/influxdb/schema"
|
||||||
|
schema.fieldKeys(bucket: "strom", predicate: (r) => r._measurement == "arbeit")'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
npm run build # Produktionsbuild
|
||||||
|
npm start
|
||||||
|
```
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { resolveRange } from '@/lib/ranges';
|
||||||
|
import { fetchVerbrauch } from '@/lib/influx';
|
||||||
|
import { RangeKey } from '@/types/strom';
|
||||||
|
|
||||||
|
const VALID_RANGES: RangeKey[] = ['24h', '7d', '31d', '365d'];
|
||||||
|
|
||||||
|
// GET /api/verbrauch?range=24h&date=YYYY-MM-DD
|
||||||
|
// ?range=7d&date=YYYY-MM-DD
|
||||||
|
// ?range=31d&month=YYYY-MM
|
||||||
|
// ?range=365d&year=YYYY
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const sp = request.nextUrl.searchParams;
|
||||||
|
const range = sp.get('range') as RangeKey | null;
|
||||||
|
|
||||||
|
if (!range || !VALID_RANGES.includes(range)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: `Ungueltiger range-Parameter. Erlaubt: ${VALID_RANGES.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = resolveRange(range, {
|
||||||
|
date: sp.get('date'),
|
||||||
|
month: sp.get('month'),
|
||||||
|
year: sp.get('year'),
|
||||||
|
});
|
||||||
|
const data = await fetchVerbrauch(resolved);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, data },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('InfluxDB error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Fehler beim Laden der Verbrauchsdaten.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* stylelint-disable at-rule-no-unknown */
|
||||||
|
@import "tailwindcss";
|
||||||
|
@source "../components/**/*.tsx";
|
||||||
|
@source "../app/**/*.tsx";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Stromverbrauch",
|
||||||
|
description: "Visualisierung des Stromverbrauchs",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const VerbrauchCharts = dynamic(() => import('@/components/VerbrauchCharts'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||||
|
<span className="text-gray-500">Lade Grafiken…</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <VerbrauchCharts />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import packageJson from '@/package.json';
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
const version = packageJson.version;
|
||||||
|
const buildDate =
|
||||||
|
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||||
|
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen py-8 px-4">
|
||||||
|
{/* Aeusserer Rahmen */}
|
||||||
|
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-6">
|
||||||
|
|
||||||
|
{/* Titel */}
|
||||||
|
<h1 className="text-4xl font-bold text-center mb-6 tracking-tight">Stromverbrauch</h1>
|
||||||
|
|
||||||
|
{/* Innerer Inhalt */}
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|
||||||
|
{/* Tab-Leiste (nur "Verlauf") */}
|
||||||
|
<div className="flex">
|
||||||
|
<span
|
||||||
|
className="px-6 py-2 text-sm font-semibold border-t-2 border-l-2 border-r-2 rounded-tl-lg rounded-tr-lg mr-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#FFFFDD',
|
||||||
|
color: '#000000',
|
||||||
|
borderColor: '#000000',
|
||||||
|
borderBottom: '2px solid #FFFFDD',
|
||||||
|
marginBottom: '-2px',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verlauf
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inhalts-Panel */}
|
||||||
|
<main className="border-2 border-black rounded-b-lg rounded-tr-lg p-6 bg-[#FFFFDD]">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
|
||||||
|
<a href="mailto:rxf@gmx.de" className="hover:underline">
|
||||||
|
mailto:rxf@gmx.de
|
||||||
|
</a>
|
||||||
|
<div>Version {version} - {buildDate}</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Highcharts from 'highcharts';
|
||||||
|
import HighchartsReact from 'highcharts-react-official';
|
||||||
|
import type { Options } from 'highcharts';
|
||||||
|
import { VerbrauchPoint, Every } from '@/types/strom';
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
title: string;
|
||||||
|
data: VerbrauchPoint[];
|
||||||
|
every: Every;
|
||||||
|
color: string;
|
||||||
|
// Optionale feste Achsengrenzen (z.B. komplettes Jahr -> immer 12 Monate sichtbar)
|
||||||
|
xMin?: number;
|
||||||
|
xMax?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
|
||||||
|
export default function BarChart({ title, data, every, color, xMin, xMax }: BarChartProps) {
|
||||||
|
const isHourly = every === '1h';
|
||||||
|
const isMonthly = every === '1mo';
|
||||||
|
const tooltipDateFormat = isHourly ? '%d.%m.%Y %H:%M' : isMonthly ? '%B %Y' : '%d.%m.%Y';
|
||||||
|
// Spaltenbreite: Stunde/Tag fix; Monat ~30 Tage breit
|
||||||
|
const pointRange = isHourly ? HOUR_MS : isMonthly ? 30 * DAY_MS : DAY_MS;
|
||||||
|
|
||||||
|
const options: Options = {
|
||||||
|
// Achse in lokaler Zeit rendern, damit Berliner Tages-/Monatsgrenzen
|
||||||
|
// (z.B. 01.01. 00:00 = 31.12. 23:00 UTC) korrekt beschriftet werden.
|
||||||
|
time: { timezone: 'Europe/Berlin' },
|
||||||
|
chart: {
|
||||||
|
type: 'column',
|
||||||
|
backgroundColor: '#F4F4F5',
|
||||||
|
style: { fontFamily: 'Arial, Helvetica, sans-serif' },
|
||||||
|
height: 300,
|
||||||
|
width: null,
|
||||||
|
zooming: { type: 'x' },
|
||||||
|
},
|
||||||
|
title: { text: title, style: { fontWeight: 'bold', fontSize: '15px' } },
|
||||||
|
xAxis: {
|
||||||
|
type: 'datetime',
|
||||||
|
min: xMin,
|
||||||
|
max: xMax,
|
||||||
|
dateTimeLabelFormats: {
|
||||||
|
hour: '%H:%M',
|
||||||
|
day: '%d.%m.',
|
||||||
|
week: '%d.%m.',
|
||||||
|
month: '%m.%Y',
|
||||||
|
},
|
||||||
|
title: { text: null },
|
||||||
|
labels: { style: { fontSize: '11px' } },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
title: { text: 'kWh' },
|
||||||
|
min: 0,
|
||||||
|
gridLineColor: '#ddd',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
xDateFormat: tooltipDateFormat,
|
||||||
|
pointFormat: '<b>{point.y:.3f} kWh</b>',
|
||||||
|
valueDecimals: 3,
|
||||||
|
},
|
||||||
|
credits: { enabled: false },
|
||||||
|
legend: { enabled: false },
|
||||||
|
plotOptions: {
|
||||||
|
column: {
|
||||||
|
borderRadius: 0,
|
||||||
|
groupPadding: 0.12,
|
||||||
|
pointPadding: 0.05,
|
||||||
|
maxPointWidth: 26,
|
||||||
|
pointRange,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'column',
|
||||||
|
name: 'Verbrauch',
|
||||||
|
data,
|
||||||
|
color,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HighchartsReact
|
||||||
|
highcharts={Highcharts}
|
||||||
|
options={options}
|
||||||
|
containerProps={{ style: { display: 'block', width: '100%' } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import AppLayout from '@/components/AppLayout';
|
||||||
|
import BarChart from '@/components/BarChart';
|
||||||
|
import { VerbrauchPoint, Every, RangeKey } from '@/types/strom';
|
||||||
|
|
||||||
|
const COLOR_24H = '#2980b9';
|
||||||
|
const COLOR_7D = '#27ae60';
|
||||||
|
const COLOR_31D = '#e67e22';
|
||||||
|
const COLOR_365D = '#8e44ad';
|
||||||
|
|
||||||
|
type SelectorKind = 'date24' | 'date' | 'month' | 'year';
|
||||||
|
|
||||||
|
function todayStr(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thisMonthStr(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
const YEARS = Array.from({ length: 11 }, (_, i) => CURRENT_YEAR - i);
|
||||||
|
|
||||||
|
interface ChartCardProps {
|
||||||
|
range: RangeKey;
|
||||||
|
every: Every;
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
selector: SelectorKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
|
||||||
|
const [value, setValue] = useState<string>(() => {
|
||||||
|
switch (selector) {
|
||||||
|
case 'date24':
|
||||||
|
return ''; // leer = rollierend letzte 24h
|
||||||
|
case 'date':
|
||||||
|
return todayStr();
|
||||||
|
case 'month':
|
||||||
|
return thisMonthStr();
|
||||||
|
case 'year':
|
||||||
|
return String(CURRENT_YEAR);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [data, setData] = useState<VerbrauchPoint[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (sel: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ range });
|
||||||
|
if (selector === 'date24' && sel) params.set('date', sel);
|
||||||
|
if (selector === 'date') params.set('date', sel);
|
||||||
|
if (selector === 'month') params.set('month', sel);
|
||||||
|
if (selector === 'year') params.set('year', sel);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/verbrauch?${params.toString()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) {
|
||||||
|
setData(json.data);
|
||||||
|
} else {
|
||||||
|
setError(json.error || 'Fehler beim Laden der Daten.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[range, selector]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(value);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Jahres-Chart: Achse immer ueber das komplette Kalenderjahr -> alle 12 Monate
|
||||||
|
// sichtbar, Restmonate bleiben ohne Balken.
|
||||||
|
let xMin: number | undefined;
|
||||||
|
let xMax: number | undefined;
|
||||||
|
if (range === '365d') {
|
||||||
|
const y = Number(value);
|
||||||
|
if (Number.isFinite(y)) {
|
||||||
|
xMin = new Date(y, 0, 1).getTime();
|
||||||
|
// bis Ende Dezember -> Januar des Folgejahres wird nicht mehr angezeigt
|
||||||
|
xMax = new Date(y + 1, 0, 1).getTime() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gesamtverbrauch des angezeigten Zeitraums
|
||||||
|
const total = data.reduce((sum, [, v]) => sum + v, 0);
|
||||||
|
const totalLabel = range === '365d' ? `Gesamtverbrauch ${value}` : 'Gesamtverbrauch';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="block w-full bg-white border border-black rounded-lg shadow-md p-3 overflow-hidden mb-6">
|
||||||
|
{/* Steuerung */}
|
||||||
|
<div className="flex flex-wrap gap-4 items-end mb-3 px-1">
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<label className="font-semibold whitespace-nowrap text-sm">
|
||||||
|
{selector === 'date24' && 'Tag (leer = letzte 24h):'}
|
||||||
|
{selector === 'date' && 'Tag:'}
|
||||||
|
{selector === 'month' && 'Monat:'}
|
||||||
|
{selector === 'year' && 'Jahr:'}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{selector === 'year' ? (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
className="border border-gray-400 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||||
|
>
|
||||||
|
{YEARS.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={selector === 'month' ? 'month' : 'date'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
className="border border-gray-400 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => fetchData(value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-[#85B7D7] hover:bg-[#6a9fc5] px-5 py-2 text-sm rounded-lg transition-colors shadow-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Lade...' : 'Anzeigen'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && <span className="text-red-600 text-sm">{error}</span>}
|
||||||
|
{!isLoading && !error && data.length === 0 && (
|
||||||
|
<span className="text-gray-500 text-sm">Keine Daten im gewählten Zeitraum.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagramm */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-16 text-gray-500">Lade Daten…</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BarChart title={title} data={data} every={every} color={color} xMin={xMin} xMax={xMax} />
|
||||||
|
{data.length > 0 && (
|
||||||
|
<div className="text-right text-sm font-semibold mt-2 px-2">
|
||||||
|
{totalLabel}:{' '}
|
||||||
|
{total.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kWh
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerbrauchCharts() {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<ChartCard range="24h" every="1h" title="Letzte 24 Stunden" color={COLOR_24H} selector="date24" />
|
||||||
|
<ChartCard range="7d" every="1h" title="Woche" color={COLOR_7D} selector="date" />
|
||||||
|
<ChartCard range="31d" every="1d" title="Letzte 31 Tage (Monat)" color={COLOR_31D} selector="month" />
|
||||||
|
<ChartCard range="365d" every="1mo" title="Jahr (pro Monat)" color={COLOR_365D} selector="year" />
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
globalIgnores([
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
turbopack: {
|
||||||
|
root: path.resolve(__dirname),
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+6755
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "strom-next",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"versiondate": "2026-06-28",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
|
"highcharts": "^12.5.0",
|
||||||
|
"highcharts-react-official": "^3.2.3",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type RangeKey = '24h' | '7d' | '31d' | '365d';
|
||||||
|
|
||||||
|
// [Zeitstempel in ms, Verbrauch in kWh]
|
||||||
|
export type VerbrauchPoint = [number, number];
|
||||||
|
|
||||||
|
export interface VerbrauchResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: VerbrauchPoint[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auflösung eines Fensters in Flux ('1h', '1d' oder '1mo')
|
||||||
|
export type Every = '1h' | '1d' | '1mo';
|
||||||
Reference in New Issue
Block a user