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:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
Generated
+7158
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user