Compare commits

...

11 Commits

Author SHA1 Message Date
admin 6e5f0bbe8d V 1.1.10:
31d geht jetzt wirklich über 31 Tage
2026-07-01 13:24:51 +02:00
admin bb827f197d Fix: Container-Zeitzone Europe/Berlin (Januar fehlte, Ladetage nicht markiert)
Ursache: Container lief in UTC, waehrend die Flux-Aggregation Europe/Berlin
nutzt. Dadurch lagen die in JS (Lokalzeit) berechneten Monats-/Tagesgrenzen
eine Stunde neben den Flux-Balken:
- Januar-Balken (Berlin = 31.12. 23:00 UTC) lag vor 'start' (UTC) und wurde
  weggefiltert.
- Ladetag-Zeitstempel (UTC-Mitternacht) passten nicht auf die Verbrauchsbalken
  (Berliner Mitternacht) -> keine Markierung.

Fix: tzdata ins Image + TZ=Europe/Berlin (Dockerfile und compose). Damit
stimmt die JS-Lokalzeit mit der Flux-Location ueberein. Verifiziert: im
Container ergibt new Date(2026,0,1) den Berliner Monatsanfang.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:13:06 +02:00
admin 67fa2de818 Fix: InfluxDB-Host im smarthome-Netz heisst 'influxdb'
INFLUX_URL in docker-compose.prod.yml auf http://influxdb:8086 korrigiert
(verursachte 500 bei allen API-Routen, da Host 'influx' nicht aufloesbar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:07:33 +02:00
admin ead1846767 deploy.sh: schlanker Build ohne separaten buildx-Builder
Statt eines docker-container-Builders (eigener, volllaufender Cache ->
"no space left on device") wird das Image jetzt mit "docker build
--platform linux/amd64" im Standard-Image-Store gebaut und per
"docker push" hochgeladen. Verifiziert: Image ist linux/amd64.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:43:53 +02:00
admin 165d97a02f deploy.sh: nur fuer linux/amd64 bauen
Statt multiplatform (amd64/arm64) wird das Image nur fuer linux/amd64
gebaut und gepusht (nuccy ist amd64).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:39:23 +02:00
admin 6fdf29f040 Docker-Deployment fuer nuccy
- Dockerfile (Next.js standalone, Multi-Stage) + .dockerignore
- deploy.sh fuer strom-next angepasst (Registry docker.citysensor.de,
  multiplatform build & push)
- docker-compose.prod.yml: Netzwerk 'smarthome' (external), InfluxDB als
  Host 'influx', alle Env-Defaults gesetzt, INFLUX_TOKEN aus .env
- .env.production.example (nur Token/Port), public/ fuer den Image-Build
- README: Deploy-Anleitung

Lokal verifiziert: Image baut, Container startet und liefert HTTP 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:34:24 +02:00
admin 8873ffe9d3 Crash endgueltig ausschliessen: keine Element-Destrukturierung mehr
Summenzeile (VerbrauchCharts) und Balken-Einfaerbung (BarChart) greifen die
Werte jetzt per Index mit Array.isArray-Guard ab, statt Elemente per [x,y] zu
destrukturieren. Damit kann "(destructured parameter) is not iterable" auch bei
unerwarteten Datenelementen nicht mehr auftreten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:03:03 +02:00
admin d711c14b59 Fix: Darstellung robust gegen unerwartete Datenelemente
TypeError "(destructured parameter) is not iterable" bei der Summenberechnung
(data.reduce mit [, v]) konnte die Seite crashen, wenn data ein Element
enthielt, das kein [number, number]-Tupel ist.

Loesung: Daten beim Laden in fetchData validieren (nur valide Tupel werden
uebernommen). Damit sind sowohl die Summenzeile als auch BarChart robust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:53:14 +02:00
admin a3a8743998 Navigations-Pfeile an allen Datumseingaben
Links/Rechts-Pfeile (zurueck/vorwaerts) neben jeder Datumsauswahl fuer
schnelle Navigation. Schrittweite je nach Auswahl: Tag (24h, 7d), Monat
(31d), Jahr (365d). Nach dem Klick wird direkt neu geladen.

- Vorwaerts-Pfeil wird deaktiviert, sobald der aktuelle Zeitraum erreicht
  ist (keine Navigation in die Zukunft)
- bei leerem 24h-Feld (rollierend) wird ab heute gerechnet

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:47:04 +02:00
admin c7f69ba7b9 Lade-Erkennung: kurze Einbrueche ueberbruecken
Statt durchgehend (stateDuration, das bei jedem Einbruch zurueckgesetzt
wurde) werden jetzt Minuten ueber der Schwelle zu Sessions zusammengefasst
und Einbrueche bis LADEN_MAX_GAP_MIN (Default 10 min) ueberbrueckt. Eine
Session ab LADEN_MIN_HOURS gilt als Ladung.

Damit werden z.B. Ladungen mit kurzem Leistungs-Einbruch (Bsp. 2026-06-20,
~14:58-17:01 mit Einbruch um 16:35) korrekt erkannt.

- Erkennung in Node auf Minuten-Mittelwerten (aggregateWindow mean, Pushdown
  -> schneller als zuvor, ~5s statt ~13s je Monat)
- Tages-Zuordnung Berlin-genau, passt exakt zu den 31d-Balken
- neuer Env-Parameter LADEN_MAX_GAP_MIN

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:23:59 +02:00
admin 46c8057155 E-Auto-Lade-Erkennung im 31d-Diagramm
Markiert im 31d-Diagramm die Tage rot, an denen das E-Auto geladen wurde:
Leistung (Feld 'leistung', W) lag laenger als LADEN_MIN_HOURS (Default 2h)
ununterbrochen ueber LADEN_THRESHOLD_KW (Default 11 kW).

- Erkennung serverseitig per Flux stateDuration auf den Sekundendaten
  (24h Vorlauf fuer ueber Mitternacht laufende Ladungen an der Monatsgrenze)
- /api/laden?month=YYYY-MM, Ergebnis gecacht (abgeschlossene Monate dauerhaft,
  laufender Monat 5 min) -> ~15s nur beim ersten Aufruf
- Markierung wird asynchron nachgeladen, Balken erscheinen sofort
- Schwelle/Dauer per Env (LADEN_THRESHOLD_KW, LADEN_MIN_HOURS) einstellbar
- BarChart: optionales highlight-Set faerbt passende Balken

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:49:44 +02:00
15 changed files with 602 additions and 17 deletions
+51
View File
@@ -0,0 +1,51 @@
# dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# testing
coverage
# next.js
.next/
out/
build
dist
# misc
.DS_Store
*.pem
# debug
*.log
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# git
.git
.gitignore
# editor
.vscode
.idea
# docker
Dockerfile
.dockerignore
docker-compose*.yml
# other
README.md
+8
View File
@@ -11,3 +11,11 @@ INFLUX_ROLLUP_MEASUREMENT=arbeit_hourly
INFLUX_FIELD=arbeit INFLUX_FIELD=arbeit
# Umrechnung auf kWh: Zaehlerstand liegt in Wh vor -> 0.001 # Umrechnung auf kWh: Zaehlerstand liegt in Wh vor -> 0.001
INFLUX_UNIT_FACTOR=0.001 INFLUX_UNIT_FACTOR=0.001
# E-Auto-Lade-Erkennung (31d-Diagramm): Leistung (W) laenger als
# LADEN_MIN_HOURS ueber LADEN_THRESHOLD_KW -> Tag markiert. Kurze Einbrueche
# (z.B. waehrend des Ladens) bis LADEN_MAX_GAP_MIN Minuten werden ueberbrueckt.
INFLUX_POWER_FIELD=leistung
LADEN_THRESHOLD_KW=11
LADEN_MIN_HOURS=2
LADEN_MAX_GAP_MIN=10
+8
View File
@@ -0,0 +1,8 @@
# Auf nuccy als ".env" neben docker-compose.prod.yml ablegen (NICHT committen).
# Nur Geheimnisse/Abweichungen; der Rest steht in docker-compose.prod.yml.
# InfluxDB Read-Token (Bucket strom)
INFLUX_TOKEN=
# Optional: abweichender Host-Port (Default 3000)
# STROM_PORT=3000
+1
View File
@@ -29,6 +29,7 @@ yarn-error.log*
.env* .env*
!.env.example !.env.example
!.env.local.example !.env.local.example
!.env.production.example
# vercel # vercel
.vercel .vercel
+56
View File
@@ -0,0 +1,56 @@
# Multi-stage build for Next.js application (standalone output)
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set build date as build argument
ARG BUILD_DATE
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
# Disable telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Zeitzone Europe/Berlin: JS-Lokalzeit muss mit der Flux-Aggregation (Berlin)
# uebereinstimmen, sonst werden Tages-/Monatsgrenzen falsch zugeordnet.
RUN apk add --no-cache tzdata
ENV TZ=Europe/Berlin
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+49 -1
View File
@@ -60,6 +60,16 @@ from(bucket:"strom")
X-Achse: Datum/Zeit · Y-Achse: kWh X-Achse: Datum/Zeit · Y-Achse: kWh
### E-Auto-Lade-Erkennung (31d)
Im 31d-Diagramm werden Tage **rot** markiert, an denen das E-Auto geladen wurde:
Die Leistung (Feld `leistung`, W) lag **länger als `LADEN_MIN_HOURS` (Default 2 h)
ununterbrochen über `LADEN_THRESHOLD_KW` (Default 11 kW)** — dem Anschlusswert des
Autos. Erkennung serverseitig per Flux `stateDuration` auf den Sekundendaten
(`/api/laden?month=YYYY-MM`); läuft ~15 s je Monat und wird daher gecacht
(abgeschlossene Monate dauerhaft). Die Markierung wird asynchron nachgeladen,
sodass die Balken sofort erscheinen. Schwelle/Dauer sind per Env einstellbar.
## Setup ## Setup
```bash ```bash
@@ -91,10 +101,48 @@ curl -s -XPOST "http://nuccy:8086/api/v2/query?org=citysensor" \
schema.fieldKeys(bucket: "strom", predicate: (r) => r._measurement == "arbeit")' schema.fieldKeys(bucket: "strom", predicate: (r) => r._measurement == "arbeit")'
``` ```
## Starten ## Starten (lokal)
```bash ```bash
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000
npm run build # Produktionsbuild npm run build # Produktionsbuild
npm start npm start
``` ```
## Docker / Deployment auf nuccy
Die App läuft als Container auf **nuccy** im externen Docker-Netzwerk
**`smarthome`** und erreicht die InfluxDB dort unter dem Host **`influxdb`**
(`http://influxdb:8086`).
### Image bauen & in die Registry pushen (vom Entwicklungsrechner)
```bash
./deploy.sh # Tag latest
./deploy.sh v1.2.0 # eigener Tag
```
Baut für `linux/amd64` und pusht nach
`docker.citysensor.de/strom-next:<tag>`.
### Auf nuccy starten
`docker-compose.prod.yml` neben eine `.env` legen (aus
`.env.production.example`) und das **InfluxDB-Read-Token** dort eintragen:
```bash
# .env
INFLUX_TOKEN=...
# optional: STROM_PORT=3000
```
Dann:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
Die App ist anschließend auf nuccy unter Port `3000` (bzw. `STROM_PORT`)
erreichbar. Alle InfluxDB-/Lade-Einstellungen sind in `docker-compose.prod.yml`
gesetzt; nur das Token kommt aus der `.env`.
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchLadetage, LADEN_THRESHOLD_KW, LADEN_MIN_HOURS } from '@/lib/laden';
// GET /api/laden?month=YYYY-MM
// Liefert die Tage (Tages-Anfangs-Zeitstempel ms), an denen das E-Auto geladen wurde.
export async function GET(request: NextRequest) {
const month = request.nextUrl.searchParams.get('month');
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json(
{ success: false, error: 'Parameter month=YYYY-MM erforderlich.' },
{ status: 400 }
);
}
try {
const days = await fetchLadetage(month);
return NextResponse.json(
{ success: true, days, thresholdKw: LADEN_THRESHOLD_KW, minHours: LADEN_MIN_HOURS },
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (error) {
console.error('Laden detection error:', error);
return NextResponse.json(
{ success: false, error: 'Fehler bei der Lade-Erkennung.' },
{ status: 500 }
);
}
}
+22 -2
View File
@@ -13,12 +13,24 @@ interface BarChartProps {
// Optionale feste Achsengrenzen (z.B. komplettes Jahr -> immer 12 Monate sichtbar) // Optionale feste Achsengrenzen (z.B. komplettes Jahr -> immer 12 Monate sichtbar)
xMin?: number; xMin?: number;
xMax?: number; xMax?: number;
// Hervorzuhebende Balken (Zeitstempel-Set, z.B. E-Auto-Ladetage) + Farbe
highlight?: Set<number>;
highlightColor?: string;
} }
const HOUR_MS = 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS; const DAY_MS = 24 * HOUR_MS;
export default function BarChart({ title, data, every, color, xMin, xMax }: BarChartProps) { export default function BarChart({
title,
data,
every,
color,
xMin,
xMax,
highlight,
highlightColor = '#c0392b',
}: BarChartProps) {
const isHourly = every === '1h'; const isHourly = every === '1h';
const isMonthly = every === '1mo'; const isMonthly = every === '1mo';
const tooltipDateFormat = isHourly ? '%d.%m.%Y %H:%M' : isMonthly ? '%B %Y' : '%d.%m.%Y'; const tooltipDateFormat = isHourly ? '%d.%m.%Y %H:%M' : isMonthly ? '%B %Y' : '%d.%m.%Y';
@@ -76,7 +88,15 @@ export default function BarChart({ title, data, every, color, xMin, xMax }: BarC
{ {
type: 'column', type: 'column',
name: 'Verbrauch', name: 'Verbrauch',
data, // markierte Balken (z.B. Ladetage) bekommen eine eigene Farbe
data:
highlight && highlight.size
? data.map((p) => {
const x = Array.isArray(p) ? p[0] : 0;
const y = Array.isArray(p) ? p[1] : 0;
return highlight.has(x) ? { x, y, color: highlightColor } : { x, y };
})
: data,
color, color,
}, },
], ],
+131 -7
View File
@@ -22,6 +22,22 @@ function thisMonthStr(): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
} }
function fmtDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(dateStr: string, delta: number): string {
const d = dateStr ? new Date(`${dateStr}T00:00:00`) : new Date();
d.setDate(d.getDate() + delta);
return fmtDate(d);
}
function addMonths(monthStr: string, delta: number): string {
const [y, m] = (monthStr || thisMonthStr()).split('-').map(Number);
const d = new Date(y, m - 1 + delta, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
const CURRENT_YEAR = new Date().getFullYear(); const CURRENT_YEAR = new Date().getFullYear();
const YEARS = Array.from({ length: 11 }, (_, i) => CURRENT_YEAR - i); const YEARS = Array.from({ length: 11 }, (_, i) => CURRENT_YEAR - i);
@@ -50,6 +66,32 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// E-Auto-Ladetage (nur 31d): werden asynchron nachgeladen
const [ladeDays, setLadeDays] = useState<Set<number>>(new Set());
const [ladeLoading, setLadeLoading] = useState(false);
const [ladeMeta, setLadeMeta] = useState<{ thresholdKw: number; minHours: number } | null>(null);
const fetchLaden = useCallback(
async (sel: string) => {
if (range !== '31d') return;
setLadeLoading(true);
setLadeDays(new Set());
try {
const res = await fetch(`/api/laden?month=${sel}`, { cache: 'no-store' });
const json = await res.json();
if (json.success) {
setLadeDays(new Set<number>(json.days));
setLadeMeta({ thresholdKw: json.thresholdKw, minHours: json.minHours });
}
} catch {
// Markierung ist sekundaer -> Fehler still ignorieren
} finally {
setLadeLoading(false);
}
},
[range]
);
const fetchData = useCallback( const fetchData = useCallback(
async (sel: string) => { async (sel: string) => {
setIsLoading(true); setIsLoading(true);
@@ -68,7 +110,18 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json(); const json = await res.json();
if (json.success) { if (json.success) {
setData(json.data); // nur valide [number, number]-Tupel uebernehmen (robust gegen
// unerwartete Elemente, damit die Darstellung nie crasht)
const clean: VerbrauchPoint[] = Array.isArray(json.data)
? (json.data as unknown[]).filter(
(p): p is VerbrauchPoint =>
Array.isArray(p) &&
p.length >= 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
: [];
setData(clean);
} else { } else {
setError(json.error || 'Fehler beim Laden der Daten.'); setError(json.error || 'Fehler beim Laden der Daten.');
} }
@@ -83,9 +136,34 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
useEffect(() => { useEffect(() => {
fetchData(value); fetchData(value);
fetchLaden(value);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleApply = () => {
fetchData(value);
fetchLaden(value);
};
// Navigation per Pfeil: einen Tag/Monat/Jahr zurueck (-1) oder vor (+1)
const step = (delta: number) => {
let nv: string;
if (selector === 'month') nv = addMonths(value, delta);
else if (selector === 'year') nv = String(Number(value || CURRENT_YEAR) + delta);
else nv = addDays(value, delta); // date24 + date
setValue(nv);
fetchData(nv);
fetchLaden(nv);
};
// Vorwaerts nicht in die Zukunft
const canForward =
selector === 'month'
? (value || thisMonthStr()) < thisMonthStr()
: selector === 'year'
? Number(value || CURRENT_YEAR) < CURRENT_YEAR
: (value || todayStr()) < todayStr();
// Jahres-Chart: Achse immer ueber das komplette Kalenderjahr -> alle 12 Monate // Jahres-Chart: Achse immer ueber das komplette Kalenderjahr -> alle 12 Monate
// sichtbar, Restmonate bleiben ohne Balken. // sichtbar, Restmonate bleiben ohne Balken.
let xMin: number | undefined; let xMin: number | undefined;
@@ -99,8 +177,8 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
} }
} }
// Gesamtverbrauch des angezeigten Zeitraums // Gesamtverbrauch des angezeigten Zeitraums (robust ohne Element-Destrukturierung)
const total = data.reduce((sum, [, v]) => sum + v, 0); const total = data.reduce((sum, p) => sum + (Array.isArray(p) ? Number(p[1]) || 0 : 0), 0);
const totalLabel = range === '365d' ? `Gesamtverbrauch ${value}` : 'Gesamtverbrauch'; const totalLabel = range === '365d' ? `Gesamtverbrauch ${value}` : 'Gesamtverbrauch';
return ( return (
@@ -115,6 +193,17 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
{selector === 'year' && 'Jahr:'} {selector === 'year' && 'Jahr:'}
</label> </label>
{/* Pfeil zurueck */}
<button
type="button"
onClick={() => step(-1)}
aria-label="zurück"
title="zurück"
className="bg-[#85B7D7] hover:bg-[#6a9fc5] px-2.5 py-1.5 text-sm rounded shadow-sm transition-colors"
>
</button>
{selector === 'year' ? ( {selector === 'year' ? (
<select <select
value={value} value={value}
@@ -135,10 +224,22 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
className="border border-gray-400 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" className="border border-gray-400 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500"
/> />
)} )}
{/* Pfeil vorwaerts (nicht in die Zukunft) */}
<button
type="button"
onClick={() => step(1)}
disabled={!canForward}
aria-label="vorwärts"
title="vorwärts"
className="bg-[#85B7D7] hover:bg-[#6a9fc5] px-2.5 py-1.5 text-sm rounded shadow-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div> </div>
<button <button
onClick={() => fetchData(value)} onClick={handleApply}
disabled={isLoading} disabled={isLoading}
className="bg-[#85B7D7] hover:bg-[#6a9fc5] px-5 py-2 text-sm rounded-lg transition-colors shadow-md disabled:opacity-50" className="bg-[#85B7D7] hover:bg-[#6a9fc5] px-5 py-2 text-sm rounded-lg transition-colors shadow-md disabled:opacity-50"
> >
@@ -156,13 +257,36 @@ function ChartCard({ range, every, title, color, selector }: ChartCardProps) {
<div className="text-center py-16 text-gray-500">Lade Daten</div> <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} /> <BarChart
title={title}
data={data}
every={every}
color={color}
xMin={xMin}
xMax={xMax}
highlight={range === '31d' ? ladeDays : undefined}
/>
<div className="flex justify-between items-center mt-2 px-2 text-sm">
{/* Legende: E-Auto-Ladetage (nur 31d) */}
{range === '31d' ? (
<span className="text-gray-600 flex items-center gap-1.5">
<span className="inline-block w-3 h-3 rounded-sm" style={{ backgroundColor: '#c0392b' }} />
{ladeLoading
? 'E-Auto-Ladung wird geprüft…'
: ladeDays.size > 0
? `E-Auto geladen (> ${ladeMeta?.thresholdKw ?? ''} kW, ≥ ${ladeMeta?.minHours ?? ''} h)`
: 'Keine E-Auto-Ladung erkannt'}
</span>
) : (
<span />
)}
{data.length > 0 && ( {data.length > 0 && (
<div className="text-right text-sm font-semibold mt-2 px-2"> <span className="font-semibold whitespace-nowrap">
{totalLabel}:{' '} {totalLabel}:{' '}
{total.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kWh {total.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kWh
</div> </span>
)} )}
</div>
</> </>
)} )}
</div> </div>
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Deploy Script für strom-next
# Baut das Docker Image und lädt es zu docker.citysensor.de hoch
set -e
# Konfiguration
REGISTRY="docker.citysensor.de"
IMAGE_NAME="strom-next"
TAG="${1:-latest}" # Erster Parameter oder "latest"
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
# Build-Datum
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo "strom-next Deploy Script"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
# 1. Login zur Registry (falls noch nicht eingeloggt)
echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}"
echo ""
# 2. Docker Image bauen (nur linux/amd64) - nutzt den Standard-Image-Store
echo ">>> Baue Docker Image (linux/amd64)..."
docker build \
--platform linux/amd64 \
--build-arg BUILD_DATE="${BUILD_DATE}" \
-t "${FULL_IMAGE}" \
.
echo ""
# 3. In die Registry pushen
echo ">>> Pushe ${FULL_IMAGE}..."
docker push "${FULL_IMAGE}"
echo ">>> Build und Push erfolgreich!"
echo ""
echo "=========================================="
echo "✓ Deploy erfolgreich abgeschlossen!"
echo "=========================================="
echo ""
echo "Auf nuccy ausführen (Verzeichnis mit docker-compose.prod.yml + .env):"
echo " docker compose -f docker-compose.prod.yml pull"
echo " docker compose -f docker-compose.prod.yml up -d"
echo ""
+35
View File
@@ -0,0 +1,35 @@
# Docker Compose für nuccy
# Container im externen Docker-Netzwerk 'smarthome', erreicht InfluxDB als 'influxdb'.
# Geheimnisse (INFLUX_TOKEN) kommen aus einer .env-Datei neben dieser Compose-Datei.
services:
strom-app:
image: docker.citysensor.de/strom-next:latest
container_name: strom-next-app
restart: unless-stopped
ports:
- "${STROM_PORT:-3000}:3000"
environment:
- NODE_ENV=production
# Zeitzone (muss zur Flux-Aggregation Europe/Berlin passen)
- TZ=Europe/Berlin
# InfluxDB im smarthome-Netz
- INFLUX_URL=http://influxdb:8086
- INFLUX_TOKEN=${INFLUX_TOKEN}
- INFLUX_ORG=citysensor
- INFLUX_BUCKET=strom
# Rohdaten (24h) bzw. Rollup (7d/31d/365d)
- INFLUX_MEASUREMENT=vzlogger
- INFLUX_ROLLUP_MEASUREMENT=arbeit_hourly
- INFLUX_FIELD=arbeit
- INFLUX_UNIT_FACTOR=0.001
# E-Auto-Lade-Erkennung
- INFLUX_POWER_FIELD=leistung
- LADEN_THRESHOLD_KW=11
- LADEN_MIN_HOURS=2
- LADEN_MAX_GAP_MIN=10
networks:
- smarthome
networks:
smarthome:
external: true
+140
View File
@@ -0,0 +1,140 @@
import { InfluxDB } from '@influxdata/influxdb-client';
import { resolveRange } from '@/lib/ranges';
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 MEASUREMENT = process.env.INFLUX_MEASUREMENT || 'vzlogger';
const POWER_FIELD = process.env.INFLUX_POWER_FIELD || 'leistung'; // momentane Leistung in W
const TZ = process.env.INFLUX_TZ || 'Europe/Berlin';
// E-Auto-Ladung: Leistung laenger als MIN_HOURS ueber THRESHOLD_KW, wobei kurze
// Einbrueche bis MAX_GAP_MIN ueberbrueckt werden (z.B. waehrend des Ladens).
export const LADEN_THRESHOLD_KW = parseFloat(process.env.LADEN_THRESHOLD_KW || '11');
export const LADEN_MIN_HOURS = parseFloat(process.env.LADEN_MIN_HOURS || '2');
export const LADEN_MAX_GAP_MIN = parseFloat(process.env.LADEN_MAX_GAP_MIN || '10');
const MIN_MS = 60 * 1000;
const influx = new InfluxDB({ url: URL, token: TOKEN, timeout: 90_000 });
interface CacheEntry {
days: number[];
cachedAt: number;
}
const cache = new Map<string, CacheEntry>();
const CURRENT_MONTH_TTL = 5 * 60 * 1000; // laufender Monat: 5 min; abgeschlossene Monate: dauerhaft
function isCurrentMonth(month: string): boolean {
const now = new Date();
const cur = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
return month >= cur;
}
// Beginn des Berliner Kalendertages (= x-Wert der 31d-Balken) zu einem Zeitstempel
function dayStart(ts: number): number {
const d = new Date(ts);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
// Alle Berliner Tage, die ein Zeitraum [start, end] beruehrt
function daysInSpan(start: number, end: number): number[] {
const out: number[] = [];
for (let cur = dayStart(start); cur <= end; ) {
out.push(cur);
const d = new Date(cur);
d.setDate(d.getDate() + 1);
cur = d.getTime();
}
return out;
}
/**
* Ermittelt die Tage eines Monats, an denen das E-Auto geladen wurde.
*
* Vorgehen: Minuten-Mittel der Leistung holen, Minuten ueber der Schwelle zu
* Sessions zusammenfassen und dabei Einbrueche bis MAX_GAP_MIN ueberbruecken.
* Eine Session ab Gesamtdauer MIN_HOURS gilt als Ladung; die beruehrten Tage
* werden markiert. Rueckgabe = Tages-Anfangs-Zeitstempel (ms, Berliner Tag),
* passend zu den x-Werten der 31d-Balken.
*
* Laeuft je Monat ~10-15s auf den Sekundendaten, daher Caching.
*/
export async function fetchLadetage(month: string): Promise<number[]> {
const thresholdW = LADEN_THRESHOLD_KW * 1000;
const minDurationMs = LADEN_MIN_HOURS * 3600 * 1000;
const maxGapMs = LADEN_MAX_GAP_MIN * 60 * 1000;
const key = `${month}|${thresholdW}|${minDurationMs}|${maxGapMs}`;
const hit = cache.get(key);
if (hit && (!isCurrentMonth(month) || Date.now() - hit.cachedAt < CURRENT_MONTH_TTL)) {
return hit.days;
}
const { start, stop } = resolveRange('31d', { month });
// 24h Vorlauf, damit ueber Mitternacht laufende Ladungen am Monatsanfang
// korrekt erkannt werden.
const queryStart = new Date(start.getTime() - 24 * 3600 * 1000);
const flux = `
import "timezone"
option location = timezone.location(name: "${TZ}")
from(bucket: "${BUCKET}")
|> range(start: ${queryStart.toISOString()}, stop: ${stop.toISOString()})
|> filter(fn: (r) => r._measurement == "${MEASUREMENT}")
|> filter(fn: (r) => r._field == "${POWER_FIELD}")
|> aggregateWindow(every: 1m, fn: mean, createEmpty: false)
|> keep(columns: ["_time", "_value"])
`;
// Minuten ueber der Schwelle einsammeln (aufsteigend)
const aboveMinutes: number[] = [];
const queryApi = influx.getQueryApi(ORG);
await new Promise<void>((resolve, reject) => {
queryApi.queryRows(flux, {
next(row, tableMeta) {
const o = tableMeta.toObject(row);
const w = Number(o._value);
if (Number.isFinite(w) && w > thresholdW) {
aboveMinutes.push(new Date(o._time as string).getTime());
}
},
error: reject,
complete: resolve,
});
});
aboveMinutes.sort((a, b) => a - b);
// Sessions bilden, kurze Einbrueche (<= maxGapMs) ueberbruecken
const markedDays = new Set<number>();
if (aboveMinutes.length > 0) {
let sessionStart = aboveMinutes[0];
let prev = aboveMinutes[0];
const finish = (s: number, e: number) => {
// Sessiondauer inkl. der letzten Minute
if (e - s + MIN_MS >= minDurationMs) {
for (const day of daysInSpan(s, e)) markedDays.add(day);
}
};
for (let i = 1; i < aboveMinutes.length; i++) {
if (aboveMinutes[i] - prev <= maxGapMs) {
prev = aboveMinutes[i];
} else {
finish(sessionStart, prev);
sessionStart = aboveMinutes[i];
prev = aboveMinutes[i];
}
}
finish(sessionStart, prev);
}
// nur Tage des angefragten Monats (Vorlauftag verwerfen)
const days = [...markedDays]
.filter((ts) => ts >= start.getTime() && ts < stop.getTime())
.sort((a, b) => a - b);
cache.set(key, { days, cachedAt: Date.now() });
return days;
}
+12 -1
View File
@@ -20,7 +20,8 @@ const DAY_MS = 24 * HOUR_MS;
* - 24h: ohne date -> rollierend die letzten 24h ab jetzt (1 Balken/Stunde) * - 24h: ohne date -> rollierend die letzten 24h ab jetzt (1 Balken/Stunde)
* mit date -> dieser Kalendertag 00:00-24:00 * mit date -> dieser Kalendertag 00:00-24:00
* - 7d: 7 Tage endend am gewaehlten Tag (Default heute), 1 Balken/Stunde * - 7d: 7 Tage endend am gewaehlten Tag (Default heute), 1 Balken/Stunde
* - 31d: kompletter gewaehlter Monat (Default aktueller Monat), 1 Balken/Tag * - 31d: aktueller Monat (Default) -> rollierend die letzten 31 Tage endend heute;
* ein konkret gewaehlter (vergangener) Monat -> dieser Kalendermonat. 1 Balken/Tag
* - 365d: komplettes gewaehltes Jahr (Default aktuelles Jahr), 1 Balken/Tag * - 365d: komplettes gewaehltes Jahr (Default aktuelles Jahr), 1 Balken/Tag
*/ */
export function resolveRange( export function resolveRange(
@@ -50,6 +51,16 @@ export function resolveRange(
const { year, month } = params.month const { year, month } = params.month
? parseMonth(params.month) ? parseMonth(params.month)
: { year: now.getFullYear(), month: now.getMonth() }; : { year: now.getFullYear(), month: now.getMonth() };
// Default/laufender Monat: rollierend die letzten 31 Tage endend heute
// (analog zu 24h). Tagesgrenzen lokal via Datums-Arithmetik -> DST-sicher.
if (year === now.getFullYear() && month === now.getMonth()) {
const stop = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); // Beginn morgen
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1 - 31);
return { start, stop, every: '1d', source: 'rollup' };
}
// Konkret gewaehlter (vergangener) Monat: kompletter Kalendermonat
const start = new Date(year, month, 1); const start = new Date(year, month, 1);
const stop = new Date(year, month + 1, 1); const stop = new Date(year, month + 1, 1);
return { start, stop, every: '1d', source: 'rollup' }; return { start, stop, every: '1d', source: 'rollup' };
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "strom-next", "name": "strom-next",
"version": "1.0.0", "version": "1.1.0",
"versiondate": "2026-06-28", "versiondate": "2026-07-01",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
View File