Initial commit: Auto-Charger Logging Web-App

MQTT-Collector (Python/paho-mqtt) speichert abgeschlossene go-e
Wallbox-Ladungen in SQLite; Next.js-Web-App zeigt sie als Tabelle mit
Energie-Summen (Monat/Jahr/gesamt) im werte-next-Design. Zwei
Docker-Container mit gemeinsamem DB-Volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 19:12:04 +02:00
commit 7dd3215520
26 changed files with 8022 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# dependencies
node_modules
npm-debug.log*
# next.js
.next/
out/
build
dist
# misc
.DS_Store
*.pem
*.log
# local env files
.env
.env.local
# typescript
*.tsbuildinfo
# git
.git
.gitignore
# editor
.vscode
.idea
# docker
Dockerfile
.dockerignore
+43
View File
@@ -0,0 +1,43 @@
# Multi-stage build für die Next.js-App (standalone output)
FROM node:22-alpine AS base
# Dependencies (better-sqlite3 ist ein natives Modul -> Build-Tools nötig)
FROM base AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BUILD_DATE
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
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"]
+22
View File
@@ -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;
}
+32
View File
@@ -0,0 +1,32 @@
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: "Auto-Charger Log",
description: "Protokoll der Auto-Ladevorgänge (go-e Wallbox)",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import AppLayout from "@/components/AppLayout";
import ChargesTable from "@/components/ChargesTable";
import Totals from "@/components/Totals";
import { getCharges, getTotals } from "@/lib/db";
// Immer frisch aus der DB lesen (kein Caching).
export const dynamic = "force-dynamic";
export default function Home() {
const charges = getCharges();
const totals = getTotals();
return (
<AppLayout>
<h2 className="text-xl font-semibold mb-4">Ladungen</h2>
<ChargesTable charges={charges} />
<Totals totals={totals} />
</AppLayout>
);
}
+37
View File
@@ -0,0 +1,37 @@
import packageJson from "@/package.json";
export default function AppLayout({ children }: { children: React.ReactNode }) {
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">
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-6">
<h1 className="text-4xl font-bold text-center mb-6 tracking-tight">
Auto-Charger Log
</h1>
<div className="max-w-6xl mx-auto">
<main className="border-2 border-black rounded-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>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Charge } from "@/lib/db";
const WEEKDAYS = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
function parts(iso: string) {
// erwartet "YYYY-MM-DDTHH:MM:SS" (lokale Zeit vom Collector)
const [date = "", time = ""] = iso.split("T");
const [y, m, d] = date.split("-");
return { y, m, d, hm: time.slice(0, 5) };
}
function formatDate(iso: string): string {
const { y, m, d } = parts(iso);
return y && m && d ? `${d}.${m}.${y}` : iso;
}
function weekday(iso: string): string {
const { y, m, d } = parts(iso);
if (!y || !m || !d) return "-";
return WEEKDAYS[new Date(Number(y), Number(m) - 1, Number(d)).getDay()];
}
function formatTime(iso: string): string {
return parts(iso).hm || "-";
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const min = Math.floor((seconds % 3600) / 60);
return `${h}:${String(min).padStart(2, "0")}`;
}
export default function ChargesTable({ charges }: { charges: Charge[] }) {
if (charges.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
Noch keine Ladungen erfasst
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#CCCCFF] border-b-2 border-gray-400">
<th className="p-2 text-center">Datum</th>
<th className="p-2 text-center">Tag</th>
<th className="p-2 text-center">Start</th>
<th className="p-2 text-center">Ende</th>
<th className="p-2 text-center">
Dauer<br />
<span className="text-xs font-normal">h:mm</span>
</th>
<th className="p-2 text-center">
Energie<br />
<span className="text-xs font-normal">kWh</span>
</th>
</tr>
</thead>
<tbody>
{charges.map((c, index) => (
<tr
key={c.id}
className={`border-b border-gray-300 hover:bg-gray-50 ${
index % 2 === 0 ? "bg-white" : "bg-gray-50"
}`}
>
<td className="p-2 text-center">{formatDate(c.start_time)}</td>
<td className="p-2 text-center">{weekday(c.start_time)}</td>
<td className="p-2 text-center">{formatTime(c.start_time)}</td>
<td className="p-2 text-center">{formatTime(c.end_time)}</td>
<td className="p-2 text-center">{formatDuration(c.duration_s)}</td>
<td className="p-2 text-center">
{(c.energy_wh / 1000).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Totals as TotalsData } from "@/lib/db";
function kwh(wh: number): string {
return (wh / 1000).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function formatInstall(iso: string | null): string {
if (!iso) return "";
const [date = ""] = iso.split("T");
const [y, m, d] = date.split("-");
return y && m && d ? ` (seit ${d}.${m}.${y})` : "";
}
function Card({ label, value }: { label: string; value: string }) {
return (
<div className="flex-1 min-w-[10rem] border border-black rounded-lg bg-white p-4 text-center">
<div className="text-sm text-gray-600">{label}</div>
<div className="text-2xl font-bold">
{value} <span className="text-base font-normal">kWh</span>
</div>
</div>
);
}
export default function Totals({ totals }: { totals: TotalsData }) {
return (
<div className="mt-6">
<h2 className="text-lg font-semibold mb-3">Geladene Energie</h2>
<div className="flex flex-wrap gap-4">
<Card label="Aktueller Monat" value={kwh(totals.month_wh)} />
<Card label="Aktuelles Jahr" value={kwh(totals.year_wh)} />
<Card
label={`Gesamt${formatInstall(totals.install_date)}`}
value={kwh(totals.total_wh)}
/>
</div>
</div>
);
}
+16
View File
@@ -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;
+86
View File
@@ -0,0 +1,86 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.DB_PATH || "/data/charges.db";
export interface Charge {
id: number;
start_time: string;
end_time: string;
duration_s: number;
energy_wh: number;
}
export interface Totals {
month_wh: number;
year_wh: number;
total_wh: number;
install_date: string | null;
}
let db: Database.Database | null = null;
/**
* Öffnet die DB read-only. Gibt null zurück, falls die Datei noch nicht
* existiert (Collector legt sie beim ersten Start an) oder noch kein Schema
* vorhanden ist — die Seite bleibt dann leer statt zu crashen.
*/
function getDb(): Database.Database | null {
if (db) return db;
try {
const conn = new Database(DB_PATH, { readonly: true, fileMustExist: true });
const hasTable = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='charges'")
.get();
if (!hasTable) {
conn.close();
return null;
}
db = conn;
return db;
} catch {
return null;
}
}
export function getCharges(): Charge[] {
const conn = getDb();
if (!conn) return [];
return conn
.prepare(
`SELECT id, start_time, end_time, duration_s, energy_wh
FROM charges ORDER BY start_time DESC`,
)
.all() as Charge[];
}
export function getTotals(): Totals {
const conn = getDb();
if (!conn) {
return { month_wh: 0, year_wh: 0, total_wh: 0, install_date: null };
}
const month = conn
.prepare(
`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges
WHERE strftime('%Y-%m', start_time) = strftime('%Y-%m', 'now', 'localtime')`,
)
.get() as { s: number };
const year = conn
.prepare(
`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges
WHERE strftime('%Y', start_time) = strftime('%Y', 'now', 'localtime')`,
)
.get() as { s: number };
const total = conn
.prepare(`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges`)
.get() as { s: number };
const install = conn
.prepare(`SELECT value FROM meta WHERE key = 'install_date'`)
.get() as { value: string } | undefined;
return {
month_wh: month.s,
year_wh: year.s,
total_wh: total.s,
install_date: install?.value ?? null,
};
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+26
View File
@@ -0,0 +1,26 @@
import type { NextConfig } from "next";
import path from "path";
const nextConfig: NextConfig = {
output: "standalone",
// Natives Modul nicht ins Bundle ziehen, sondern zur Laufzeit laden.
serverExternalPackages: ["better-sqlite3"],
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;
+7158
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "auto-charger-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
View File
+41
View File
@@ -0,0 +1,41 @@
{
"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"
],
"exclude": [
"node_modules"
]
}