Files
Smart-Dashboard/js/solar/settings.js
T
adminandClaude Opus 5 d285ec27a0 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>
2026-09-02 21:21:27 +02:00

255 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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);
});
});