Einstellungsseite, Skoda-Seite und Karte

Sammelstand der offenen Arbeit im Dashboard.

Einstellungen: eine Eingabemaske fuer die Energiepreise unter
restricted/settings.php, dazu costs.php als Modell und ajax/settings.php
als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts
statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber
LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen
Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km,
kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern.
solarLog_costs.sql beschreibt die Umstellung der Tabellen.

Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber
ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php.

Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php),
dessen Ablage unter tiles/ nicht ins Repository gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 21:21:27 +02:00
co-authored by Claude Opus 5
parent c9a9e16b21
commit d285ec27a0
26 changed files with 4862 additions and 190 deletions
+254
View File
@@ -0,0 +1,254 @@
/*
* Einstellungsseite: Preistabellen bearbeiten.
*
* Eine Preistabelle ist eine Zeitreihe - ab einem Stichtag gilt ein Preis, bis
* der naechste Stichtag kommt. Deshalb gibt es hier nur ein Datumsfeld je
* Zeile. Wo eine Zeile endet, steht als grauer Text daneben und ergibt sich
* aus der naechsten; die juengste Zeile endet gar nicht, sie gilt weiter, bis
* ein neuer Preis eingetragen wird.
*
* Frueher stand das Ende als eigene Spalte in der Datenbank, und weil es
* eigenstaendig gepflegt wurde, konnte es dem naechsten Stichtag widersprechen:
* zwei Zeilen liefen ueber dasselbe Quartal, die Jahresstatistik zaehlte diese
* Tage doppelt. Ohne die Spalte laesst sich das nicht mehr hinschreiben.
*
* Gezeichnet wird aus dem Modell, nicht aus dem DOM: karte._modell ist die
* Wahrheit, jede Eingabe schreibt hinein und loest ein Neuzeichnen aus.
*/
// --- Datumskram ------------------------------------------------------------
/** Ein Datum "JJJJ-MM-TT" um $tage verschieben. */
function tagVersatz(iso, tage) {
const d = new Date(iso + "T00:00:00Z");
d.setUTCDate(d.getUTCDate() + tage);
return d.toISOString().slice(0, 10);
}
/** "2025-01-01" -> "01.01.2025" */
function datumLesbar(iso) {
if (!iso) return "";
const t = iso.split("-");
return t[2] + "." + t[1] + "." + t[0];
}
function heuteISO() {
const d = new Date();
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}
// --- Modell ---------------------------------------------------------------
/** Zeilen aufsteigend nach Stichtag. */
function sortiert(zeilen) {
return zeilen.slice().sort((a, b) => (a.ab < b.ab ? -1 : a.ab > b.ab ? 1 : 0));
}
/**
* Die einzige Luecke, die es noch geben kann: vor dem aeltesten Stichtag gibt
* es keinen Preis. Messwerte, die davor liegen, fallen aus der Jahresstatistik
* heraus, weil der JOIN fuer sie keine Zeile findet. Passieren kann das, wenn
* jemand den aeltesten Zeitraum loescht oder nach hinten schiebt - deshalb
* wird die Warnung bei jedem Neuzeichnen geprueft, nicht nur beim Laden.
*/
function zeitraumWarnung(zeilen) {
const liste = sortiert(zeilen);
if (!ersteMessung || !liste.length || liste[0].ab <= ersteMessung) return [];
return ["Die Messwerte beginnen am " + datumLesbar(ersteMessung) + ", der älteste Preis"
+ " erst am " + datumLesbar(liste[0].ab) + ". Alles davor fehlt in der Statistik."];
}
// --- Zeichnen -------------------------------------------------------------
function preisZeile(karte, zeile, i, anzahl) {
const daten = preisDaten[karte.dataset.reihe];
const felder = daten.felder;
// Die Spaltenbreiten kommen aus preisSpalten() im PHP - Kopfzeile und
// Felder muessen dieselben benutzen, sonst stehen die Ueberschriften schief.
const breit = daten.spalten;
const letzte = (i === anzahl - 1);
const heute = heuteISO();
// Die juengste Zeile hat kein Ende; alle anderen enden am Tag vor der
// naechsten.
const bis = letzte ? null : tagVersatz(karte._modell[i + 1].ab, -1);
const gilt = zeile.ab <= heute && (bis === null || heute <= bis);
let html = "<div class='row g-2 align-items-center preis-zeile" + (gilt ? " preis-heute" : "")
+ "' data-i='" + i + "'>";
html += "<div class='col-6 col-xl-" + breit.ab + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig ab</label>"
+ "<input type='date' class='form-control form-control-sm' data-feld='ab' value='" + zeile.ab + "'>"
+ "</div>";
// Die Marke "gilt heute" steht beim Zeitraum, nicht beim Papierkorb: sie
// beschreibt ihn, und die Aktionsspalte bleibt schmal genug, dass die
// Felder den Platz bekommen.
html += "<div class='col-6 col-xl-" + breit.bis + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>Gültig bis</label>"
+ "<div class='form-control form-control-sm border-0 bg-transparent px-0"
+ " d-flex align-items-center gap-2'>"
+ "<span class='text-secondary'>" + (bis === null ? "offen" : datumLesbar(bis)) + "</span>"
+ (gilt ? "<span class='badge text-bg-primary'>gilt heute</span>" : "")
+ "</div></div>";
Object.keys(felder).forEach(function (spalte, nr) {
const feld = felder[spalte];
// Schrittweite aus den Nachkommastellen: der Energieinhalt je Liter will
// vier Stellen, ein Preis in Cent nur zwei.
const schritt = Math.pow(10, -feld.stellen).toFixed(feld.stellen);
html += "<div class='col-6 col-xl-" + breit.felder[nr] + "'>"
+ "<label class='form-label mb-0 d-xl-none small text-secondary'>" + feld.titel + "</label>"
+ "<div class='input-group input-group-sm'>"
+ "<input type='number' step='" + schritt + "' min='0' max='" + feld.max + "'"
+ " class='form-control text-end' data-feld='" + spalte + "'"
+ " value='" + zeile[spalte] + "'>"
+ "<span class='input-group-text'>" + feld.einheit + "</span>"
+ "</div></div>";
});
html += "<div class='col-12 col-xl-" + breit.aktion + " d-flex align-items-center justify-content-end'>";
html += "<button type='button' class='btn btn-sm btn-outline-danger preis-weg' title='Zeitraum löschen'"
+ (anzahl > 1 ? "" : " disabled") + "><i class='bi bi-trash'></i></button>"
+ "</div></div>";
return html;
}
function zeichnePreise(karte) {
karte._modell = sortiert(karte._modell);
const anzahl = karte._modell.length;
const warnung = zeitraumWarnung(karte._modell);
let html = "";
if (warnung.length) {
html += "<div class='alert alert-warning py-2 small'>"
+ "<i class='bi bi-exclamation-triangle me-1'></i>"
+ warnung.join(" ") + "</div>";
}
for (let i = 0; i < anzahl; i++) {
html += preisZeile(karte, karte._modell[i], i, anzahl);
}
karte.querySelector(".preis-zeilen").innerHTML = html;
}
// --- Bedienung ------------------------------------------------------------
function preisHinweis(karte, text, klasse) {
const feld = karte.querySelector(".preis-hinweis");
feld.className = "preis-hinweis small " + (klasse || "text-secondary");
feld.textContent = text;
}
function preisNeueZeile(karte) {
const modell = karte._modell;
const vorlage = modell[modell.length - 1];
// Der neue Stichtag ist heute; ist der schon vergeben, der naechste freie
// Tag. So entsteht nie eine Zeile, die das Speichern gleich ablehnt.
let ab = heuteISO();
while (modell.some(z => z.ab === ab)) {
ab = tagVersatz(ab, 1);
}
const zeile = { ab: ab };
for (const spalte in preisDaten[karte.dataset.reihe].felder) {
zeile[spalte] = vorlage[spalte]; // Preise uebernehmen, geaendert wird meist nur einer
}
modell.push(zeile);
zeichnePreise(karte);
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
const zeilen = karte.querySelectorAll(".preis-zeile");
const feld = zeilen[zeilen.length - 1].querySelector("[data-feld='ab']");
feld.focus();
}
async function preiseSenden(karte) {
const knopf = karte.querySelector(".preis-speichern");
knopf.disabled = true;
preisHinweis(karte, "Wird gespeichert …");
try {
const antwort = await fetch("ajax/settings.php?action=save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reihe: karte.dataset.reihe, zeilen: karte._modell })
});
const daten = await antwort.json();
if (!antwort.ok) {
throw new Error(daten.error || "Fehler " + antwort.status);
}
// Mit der Antwort des Servers weiterarbeiten, nicht mit dem eigenen
// Modell: sortiert und gerundet wird dort.
karte._modell = daten.zeilen;
zeichnePreise(karte);
preisHinweis(karte, "Gespeichert " + daten.gespeichert + " Zeiträume.", "text-success");
} catch (fehler) {
preisHinweis(karte, fehler.message, "text-danger");
}
knopf.disabled = false;
}
async function preiseHolen(karte) {
preisHinweis(karte, "Wird geladen …");
try {
const antwort = await fetch("ajax/settings.php?action=list&reihe=" + encodeURIComponent(karte.dataset.reihe));
const daten = await antwort.json();
if (!antwort.ok) {
throw new Error(daten.error || "Fehler " + antwort.status);
}
karte._modell = daten.zeilen;
zeichnePreise(karte);
preisHinweis(karte, "");
} catch (fehler) {
preisHinweis(karte, fehler.message, "text-danger");
}
}
function preisKarteBinden(karte) {
karte.addEventListener("input", function (ereignis) {
const feld = ereignis.target.closest("[data-feld]");
if (!feld) return;
const zeile = karte._modell[parseInt(feld.closest(".preis-zeile").dataset.i, 10)];
if (feld.dataset.feld === "ab") {
if (!feld.value) return; // waehrend des Tippens ist das Feld kurz leer
zeile.ab = feld.value;
} else {
zeile[feld.dataset.feld] = isNaN(feld.valueAsNumber) ? 0 : feld.valueAsNumber;
}
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
});
// Ein geaenderter Stichtag verschiebt die Reihenfolge und damit die Enden
// aller Zeilen - aber erst, wenn das Feld fertig ist. Waehrend des Tippens
// neu zu zeichnen wuerde den Eingabefokus wegreissen.
karte.addEventListener("change", function (ereignis) {
if (ereignis.target.dataset.feld === "ab") {
zeichnePreise(karte);
}
});
karte.addEventListener("click", function (ereignis) {
if (ereignis.target.closest(".preis-neu")) {
preisNeueZeile(karte);
} else if (ereignis.target.closest(".preis-weg")) {
const i = parseInt(ereignis.target.closest(".preis-zeile").dataset.i, 10);
karte._modell.splice(i, 1);
zeichnePreise(karte);
preisHinweis(karte, "Noch nicht gespeichert.", "text-warning");
} else if (ereignis.target.closest(".preis-zurueck")) {
preiseHolen(karte);
} else if (ereignis.target.closest(".preis-speichern")) {
preiseSenden(karte);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".preis-karte").forEach(function (karte) {
const daten = preisDaten[karte.dataset.reihe];
if (!daten) return;
karte._modell = sortiert(daten.zeilen);
zeichnePreise(karte);
preisKarteBinden(karte);
});
});
File diff suppressed because it is too large Load Diff