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:
File diff suppressed because one or more lines are too long
@@ -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
Reference in New Issue
Block a user