diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 87e733b..c253b80 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -16,6 +16,7 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /home/ubuntu/balkonkraftwerk-staging - git pull origin dev + git fetch origin dev + git reset --hard origin/dev sudo systemctl restart balkonkraftwerk-staging - echo "Staging Update erfolgreich. Test-App läuft auf Port 5001!" \ No newline at end of file + echo "Staging hart auf Stand von 'dev' gesetzt. Port 5001 läuft!" \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 240c733..6c2cb33 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,6 +16,7 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /home/ubuntu/ - git pull origin main + # Verhindert Divergenz-Fehler durch striktes Fast-Forward + git pull origin main --ff-only sudo systemctl restart balkonkraftwerk - echo "Update erfolgreich. App wurde neu gestartet!" \ No newline at end of file + echo "Produktiv-Update via Fast-Forward erfolgreich. App läuft!" \ No newline at end of file diff --git a/database.py b/database.py index 61cbdda..4272f1c 100644 --- a/database.py +++ b/database.py @@ -75,7 +75,15 @@ def init_db(): c.execute("SELECT COUNT(*) FROM prices") if c.fetchone()[0] == 0: c.execute("INSERT INTO prices (valid_from, price) VALUES ('2026-01-01', 0.329)") - + + # 5. User Settings Tabelle + c.execute('''CREATE TABLE IF NOT EXISTS user_settings + (key TEXT PRIMARY KEY, value TEXT)''') + c.execute('''INSERT OR IGNORE INTO user_settings + (key, value) VALUES ('removed_cards', '[]')''') + c.execute('''INSERT OR IGNORE INTO user_settings + (key, value) VALUES ('dashboard_layout', NULL)''') + conn.commit() conn.close() print("Datenbank erfolgreich initialisiert.") diff --git a/ml_logic.py b/ml_logic.py index f6d3a87..1d48364 100644 --- a/ml_logic.py +++ b/ml_logic.py @@ -60,8 +60,8 @@ def build_training_data(): def train_model(): X, y = build_training_data() # Da wir mehr Features haben, sollten wir mind. 10-15 Tage haben für ein erstes Training - if len(X) < 8: #15!!! - print(f"⚠️ Nicht genug Trainingsdaten ({len(X)}/8).") #15!!! + if len(X) < 15: + print(f"⚠️ Nicht genug Trainingsdaten ({len(X)}/15).") return None # Feature-Liste erweitert diff --git a/routes.py b/routes.py index c2edb38..1dc99c4 100644 --- a/routes.py +++ b/routes.py @@ -2,6 +2,7 @@ import sqlite3 import datetime import requests +import json import time import math import numpy as np @@ -1049,3 +1050,51 @@ def shap_summary(): ] return jsonify(sorted(result, key=lambda x: x["mean_abs_shap"], reverse=True)) + +@api_bp.route('/api/layout', methods=['GET']) +def get_layout(): + conn = get_db_connection() + c = conn.cursor() + c.execute("SELECT value FROM user_settings WHERE key = 'dashboard_layout'") + row = c.fetchone() + conn.close() + if row: + return jsonify({"layout": json.loads(row[0])}), 200 + return jsonify({"layout": None}), 200 + +@api_bp.route('/api/layout', methods=['POST']) +def save_layout(): + try: + data = request.get_json() + if not data: + return jsonify({"error": "Ungültiges JSON"}), 400 + + layout_json = data.get('layout') + password = data.get('pw') + + if password != ADMIN_PASS: + return jsonify({"error": "Nicht autorisiert"}), 403 + + conn = get_db_connection() + c = conn.cursor() + + if layout_json == "RESET": + c.execute("DELETE FROM user_settings WHERE key = 'dashboard_layout'") + print("Layout wurde zurückgesetzt.") + elif layout_json is not None: + # WICHTIG: Wir konvertieren das Objekt explizit in einen String für die DB + layout_string = json.dumps(layout_json) + c.execute("INSERT OR REPLACE INTO user_settings (key, value) VALUES (?, ?)", + ('dashboard_layout', layout_string)) + print("Layout erfolgreich gespeichert.") + else: + conn.close() + return jsonify({"error": "Kein Layout-Inhalt empfangen"}), 400 + + conn.commit() + conn.close() + return jsonify({"status": "gespeichert"}), 200 + + except Exception as e: + print(f"Server-Fehler: {str(e)}") + return jsonify({"error": "Interner Server Fehler", "details": str(e)}), 500 diff --git a/static/css/style.css b/static/css/style.css index d6afeb4..0a39d1a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -158,31 +158,40 @@ transform 0.15s ease; } - .main-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 20px; - row-gap: 25px; - margin-bottom: 25px; + .grid-stack { + margin-left: -20px; + margin-right: -20px; + background: transparent !important; + box-shadow: 0 !important; + border: 0 !important; + border-radius: 0 !important; + } + + .grid-stack-item-content { + xoverflow-x: hidden; + xoverflow-y: auto; + overflow: visible !important; + background: transparent !important; + box-shadow: none !important; } - - /* Reihe 1 */ - .main-grid > *:nth-child(1) { grid-column: span 1; } - .main-grid > *:nth-child(2) { grid-column: span 2; } - - /* Reihe 2 & 3: Volle Breite */ - .main-grid > *:nth-child(3), - .main-grid > *:nth-child(4) { grid-column: span 3; } - - /* Reihe 4: 2fr zu 1fr */ - .main-grid > *:nth-child(5) { grid-column: span 2; } - .main-grid > *:nth-child(6) { grid-column: span 1; } - - /* AB Reihe 5 (Element 7 und alle folgenden): Volle Breite */ - .main-grid > *:nth-child(n + 7) { - grid-column: span 3; + + .edit-mode .grid-stack-item-content { + cursor: grab; + border: 1px dashed var(--accent); + } + + .edit-mode .grid-stack-item-content:active { + cursor: grabbing; } + :where(.card, .chart-card) + :where(.card, .chart-card) { + margin-top: 0; + } + + .chart-card { + height: 100%; /* Ersetzt die starren 400px */ + } + .card { background: var(--card-bg); border-radius: 24px; @@ -190,7 +199,7 @@ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03); display: flex; flex-direction: column; - justify-content: center; + justify-content: start; } .card-gradient { @@ -295,10 +304,6 @@ margin-top: 25px; } - .main-grid > :where(.card, .chart-card) { - margin-top: 0; - } - .card-peak { background: linear-gradient(135deg, #4b79ff 0%, #6dd5fa 100%); box-shadow: 0 15px 35px rgba(75, 121, 255, 0.25); @@ -384,13 +389,6 @@ } @media (max-width: 900px) { - .main-grid { - grid-template-columns: 1fr; - } - - .main-grid > * { - grid-column: span 1 !important; - } .top-controls { flex-direction: column; diff --git a/static/js/layout.js b/static/js/layout.js new file mode 100644 index 0000000..b5ed4d1 --- /dev/null +++ b/static/js/layout.js @@ -0,0 +1,133 @@ +let dashboardGrid; +let currentPw = ""; + +// 1. Grid initialisieren +function initGridstack() { + dashboardGrid = GridStack.init({ + cellHeight: 50, + margin: 20, + animate: true, + staticGrid: true, + disableOneColumnMode: false, + oneColumnModeDomSort: true + }); + + dashboardGrid.on('change', function(event, items) { + saveLayout(); + }); +} + +// 2. Layout speichern (Nur in DB und nur wenn PW da ist) +async function saveLayout() { + if (!dashboardGrid || !currentPw || currentPw === "") { + return; + } + + const layoutData = dashboardGrid.save(); + + try { + const response = await fetch('/api/layout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + layout: layoutData, + pw: currentPw + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Speichern fehlgeschlagen:", errorText); + } + } catch (e) { + console.error("Netzwerkfehler beim Speichern:", e); + } +} + +// 3. Layout beim Starten laden +async function loadLayout() { + let savedLayout = null; + try { + const response = await fetch('/api/layout'); + if (response.ok) { + const data = await response.json(); + if (data && data.layout) savedLayout = data.layout; + } + } catch (e) { console.error("DB Load failed", e); } + + // Wenn ein Layout auf dem Server existiert, anwenden + if (savedLayout && dashboardGrid) { + dashboardGrid.removeAll(); + dashboardGrid.load(savedLayout); + console.log("Globales Layout erfolgreich geladen."); + } +} + +// 4. Reset-Funktion für den Button im Admin-Bereich +async function resetDatabaseLayout() { + if (!confirm("Möchtest du das Layout für ALLE Nutzer auf den Standard zurücksetzen?")) return; + + if (!currentPw) return alert("Bitte erst als Admin einloggen!"); + + const res = await fetch('/api/layout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + layout: "RESET", // Signalwort für das Python-Backend + pw: currentPw + }) + }); + + if (res.ok) { + location.reload(); // Seite neu laden, um Standard-HTML zu zeigen + } +} + +document.addEventListener("DOMContentLoaded", async () => { + // --- PHASE 1: Das Gerüst aufbauen --- + initGridstack(); + await loadLayout(); // Wartet, bis Boxen aus DB oder LocalStorage da sind + + // --- PHASE 2: Startwerte für Datumsfelder setzen --- + const t = new Date().toISOString().split('T')[0]; + const startInput = document.getElementById('start'); + const endInput = document.getElementById('end'); + + if (startInput && endInput) { + startInput.value = t; + endInput.value = t; + startInput.addEventListener('change', updateQuickButtonsActiveState); + endInput.addEventListener('change', updateQuickButtonsActiveState); + } + + // --- PHASE 3: Daten in die Boxen pumpen --- + // Wir prüfen bei jeder Funktion, ob sie existiert, um Fehler zu vermeiden + try { + if (typeof fetchData === "function") await fetchData(); + if (typeof updateWeather === "function") updateWeather(); + if (typeof updateLive === "function") updateLive(); + if (typeof updatePeaks === "function") updatePeaks(); + if (typeof updateQuickButtonsActiveState === "function") updateQuickButtonsActiveState(); + + // ML-Funktionen + if (typeof loadForecast === "function") loadForecast(); + if (typeof loadGlobalShap === "function") loadGlobalShap(); + if (typeof loadFeatureImportance === "function") loadFeatureImportance(); + + // Heatmaps + if (typeof initHeatmapYears === "function") initHeatmapYears(); + if (typeof initHourlyHeatmap === "function") initHourlyHeatmap(); + + } catch (err) { + console.error("Fehler beim initialen Daten-Load:", err); + } + + // --- PHASE 4: Intervalle für Updates starten --- + setInterval(() => { if (typeof updateLive === "function") updateLive(); }, 5000); + setInterval(() => { if (typeof fetchData === "function") fetchData(); }, 60000); + setInterval(() => { if (typeof updatePeaks === "function") updatePeaks(); }, 60000); + setInterval(() => { if (typeof checkLoadingStatus === "function") checkLoadingStatus(); }, 500); + +}); \ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js index d76de3e..4047ebf 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -2,7 +2,6 @@ let myChart; let panelChart; let dcDonutChart; let activeDonutIndex = null; // null = Gesamtansicht, 0 = Panel 1, 1 = Panel 2 -let currentPw = ""; const ROI_DEBUG_FORCE_COMPLETE = false; @@ -27,30 +26,43 @@ async function checkLoadingStatus() { } async function unlockAdmin() { - const pw = prompt("Passwort zur Anpassung des Stromtarifs:"); - if (!pw) return; - const res = await fetch('/api/auth', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - pw: pw - }) - }); - if (res.ok) { - currentPw = pw; - document.getElementById('unlockBtn').style.display = 'none'; - document.getElementById('adminArea').style.display = 'flex'; - loadTariffs(); - } else { - alert("Falsches Passwort!"); - } + const pw = prompt("Passwort zur Administration:"); + if (!pw) return; + + const res = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pw: pw }) + }); + + if (res.ok) { + currentPw = pw; + + document.getElementById('unlockBtn').style.display = 'none'; + document.getElementById('adminArea').style.display = 'flex'; + + if (dashboardGrid) { + // Grid bearbeitbar machen + dashboardGrid.setStatic(false); + document.getElementById('dashboard-grid').classList.add('edit-mode'); + } + + loadTariffs(); + } else { + alert("Falsches Passwort!"); + } } function closeAdmin() { - document.getElementById('adminArea').style.display = 'none'; - document.getElementById('unlockBtn').style.display = 'block'; + currentPw = ""; + document.getElementById('adminArea').style.display = 'none'; + document.getElementById('unlockBtn').style.display = 'block'; + + if (dashboardGrid) { + // Grid wieder für alle sperren + dashboardGrid.setStatic(true); + document.getElementById('dashboard-grid').classList.remove('edit-mode'); + } } async function loadTariffs() { @@ -579,8 +591,12 @@ async function updateROI() { roiCard.classList.remove('roi-complete'); badgeContainer.innerHTML = ''; } - updateROIForecast(); - } catch (e) { + if (typeof updateROIForecast === "function") { + updateROIForecast(); + } else { + console.log("updateROIForecast ist noch nicht implementiert."); + } + } catch (e) { console.error("ROI Fehler", e); } @@ -716,7 +732,11 @@ document.getElementById("heatmapYearSelect") document.addEventListener("DOMContentLoaded", initHeatmapYears); +let heatmapInitialized = false; + async function initHourlyHeatmap() { + if (heatmapInitialized) return; + heatmapInitialized = true; const res = await fetch("/api/heatmap_hourly"); const data = await res.json(); @@ -836,6 +856,16 @@ async function loadHourlyHeatmap(month) { // Beim Laden der Seite aufrufen document.addEventListener("DOMContentLoaded", initHourlyHeatmap); +// ========================= +// FORECAST JS +// ========================= + +const appState = { + forecast: [], + activeIndex: 0, + chart: null +}; + function prettyFeatureName(key) { const featureNames = { clouds: "Mittlerer Bewölkungsgrad", @@ -852,186 +882,351 @@ function prettyFeatureName(key) { return featureNames[key] || key; } -async function loadForecast() { +// 🔒 VALIDATION + ERROR UI - const response = await fetch("/api/forecast"); - const data = await response.json(); +function validateForecastData(forecast) { - console.log("Forecast API:", data); + if (!Array.isArray(forecast) || forecast.length === 0) { + return "Keine Forecast-Daten vorhanden"; + } - const forecast = data.forecast || []; - const mae = data.mae || 0; + for (const f of forecast) { - if (forecast.length === 0) { - console.warn("Keine Forecast Daten vorhanden"); - return; - } + if (f.kwh_pred == null || isNaN(f.kwh_pred)) { + return "Ungültige Prognosewerte (kwh_pred fehlt)"; + } - const totalKwh = forecast.reduce((sum, d) => sum + d.kwh_pred, 0); - const totalEur = forecast.reduce((sum, d) => sum + d.eur_pred, 0); + if (f.kwh_lower == null || f.kwh_upper == null) { + return "Unsicherheitsband unvollständig"; + } - document.getElementById('forecast-total-kwh').innerHTML = `${totalKwh.toFixed(2)}kWh`; + if (!f.date) { + return "Datum fehlt in Forecast"; + } - document.getElementById("forecast-total-eur").innerHTML = `${totalEur.toFixed(2)}€`; + } - document.getElementById("forecast-mae").innerHTML = `${mae.toFixed(2)}kWh`; + return null; +} +function showForecastError(message) { + + const canvas = document.getElementById("forecastChart"); + const container = canvas.parentElement; + + container.innerHTML = ` +
ML-basierte 7-Tage Prognose auf Basis von Ertrag, Bewölkung, Sonnenhöhe und Wettervorhersage.
-ML-basierte 7-Tage Prognose auf Basis von Ertrag, Bewölkung, Sonnenhöhe und Wettervorhersage.
+ +