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
+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>
);
}