Compare commits
11 Commits
3649bf4e22
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e5f0bbe8d | |||
| bb827f197d | |||
| 67fa2de818 | |||
| ead1846767 | |||
| 165d97a02f | |||
| 6fdf29f040 | |||
| 8873ffe9d3 | |||
| d711c14b59 | |||
| a3a8743998 | |||
| c7f69ba7b9 | |||
| 46c8057155 |
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+135
-11
@@ -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
|
||||||
{data.length > 0 && (
|
title={title}
|
||||||
<div className="text-right text-sm font-semibold mt-2 px-2">
|
data={data}
|
||||||
{totalLabel}:{' '}
|
every={every}
|
||||||
{total.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kWh
|
color={color}
|
||||||
</div>
|
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 && (
|
||||||
|
<span className="font-semibold whitespace-nowrap">
|
||||||
|
{totalLabel}:{' '}
|
||||||
|
{total.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kWh
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 ""
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user