Compare commits

..
2 Commits
Author SHA1 Message Date
retr0andClaude Sonnet 5 9d954d6bd6 ShineBridge v1.9.6 — Beispiel-Diagramm im Leerzustand, Tages-/Wochen-Umschaltung an 14 Wochen geknüpft
- Chart-Logik in buildCostChart() ausgelagert, wiederverwendbar für echte und
  Beispiel-Daten
- Neu: bei Erstinstallation (noch keine echten Daten) zeigt die Finanzen-Seite
  jetzt ein klar als 'BEISPIEL' markiertes, entsättigtes Vorschau-Diagramm mit
  frei erfundenen (deterministischen) Werten statt nur eines Texthinweises —
  keine echt aussehenden Fantasiezahlen, bewusst gedämpfte Farben + Badge
- Umschaltung Tage→Wochen jetzt an 14 Wochen (98 Tage) Historie geknüpft statt
  schon nach 21 Tagen — bis dahin rollierendes Tages-Fenster, danach
  rollierendes Wochen-Fenster (älteste Periode wandert raus sobald neue dazukommt)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:19:13 +02:00
retr0andClaude Sonnet 5 5b6bed16db ShineBridge v1.9.5 — Finanzen-Chart: Container-Breite gemessen, Periodenanzahl passt sich an statt Balken aufzublasen
Statt die SVG per viewBox unbegrenzt mitwachsen zu lassen (wurde am Desktop
riesig) oder sie bei einer festen max-width zu kappen (ließ rechts Leerraum),
wird jetzt die tatsächliche Container-Breite gemessen und in echten Pixeln
gerendert — Höhe bleibt dabei komplett unabhängig fix.

Zusätzlich: auf breiten Screens werden mehr Wochen gezeigt statt derselben
14 Balken künstlich zu verbreitern (Zielbreite ~24px/Periode, min. 10, max.
28 Perioden) — nutzt den zusätzlichen Platz sinnvoll für mehr Historie statt
für aufgeblähte Einzelbalken.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:12:45 +02:00
2 changed files with 126 additions and 92 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge
version: "1.9.4"
version: "1.9.6"
slug: shinebridge
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
url: https://gitea.bitfire.work/retr0/shinebridge
+125 -91
View File
@@ -736,6 +736,122 @@ function renderEnergy(inverters, aggregates, period, spotData) {
// ── Finance Tab ───────────────────────────────────────────────
// Erzeugt plausible, aber frei erfundene Tagesdaten fürs Beispiel-Diagramm im
// Leerzustand (Erstinstallation). Deterministisch (kein echter Zufall), damit
// die Vorschau bei jedem Laden gleich aussieht statt bei jedem Reload zu springen.
function exampleFinanceDays(n) {
const out = [];
const today = new Date();
for (let i = n - 1; i >= 0; i--) {
const d = new Date(today); d.setDate(d.getDate() - i);
const wave = 4.2 + 2.6 * Math.sin(i * 0.6) + (i % 3 === 0 ? 1.4 : 0);
const fixed = Math.max(1.5, wave);
const spot = Math.max(0.8, fixed * (0.68 + 0.1 * Math.cos(i * 0.9)));
out.push({ date: d.toISOString().slice(0, 10), fixed_eur: +fixed.toFixed(2), spot_eur: +spot.toFixed(2) });
}
return out;
}
// Baut das Kosten-Balkendiagramm (echte oder Beispiel-Daten). Aggregiert ab
// 14 Wochen (98 Tagen) angesammelter Historie automatisch von Tages- auf
// Wochenbalken; zeigt davon je nach verfügbarer Breite die letzten 10–28
// Perioden (rollierendes Fenster — ältere Balken wandern rechts raus, sobald
// neue dazukommen).
function buildCostChart(days, el, isExample) {
const C = isExample
? { fixed: '#5b6472', spot: '#6b6155', grid: '#262b33' }
: { fixed: '#58a6ff', spot: '#f0883e', grid: '#30363d' };
const rawN = days.length;
const bucket = rawN >= 98 ? 7 : 1;
const allPeriods = bucket === 1 ? days.map(d => ({
fixed_eur: d.fixed_eur, spot_eur: d.spot_eur, label: d.date.slice(5),
})) : (() => {
const out = [];
for (let i = 0; i < days.length; i += bucket) {
const chunk = days.slice(i, i + bucket);
const fixed = chunk.reduce((s, d) => s + (d.fixed_eur || 0), 0);
const spotVals = chunk.map(d => d.spot_eur).filter(v => v != null);
const spot = spotVals.length ? spotVals.reduce((a, b) => a + b, 0) : null;
out.push({ fixed_eur: fixed, spot_eur: spot, label: chunk[0].date.slice(5) });
}
return out;
})();
// Container-Breite messen und die SVG in echten Pixeln in genau dieser Breite
// zeichnen (keine viewBox-Skalierung) — Höhe bleibt dabei fix, unabhängig von
// der Breite. Auf breiten Screens werden mehr Perioden gezeigt statt dieselben
// künstlich zu verbreitern.
const H = 240, PL = 40, PR = 8, PT = 14, PB = 46;
const measuredW = el.clientWidth || 380;
const W = Math.max(300, measuredW);
const cW = W - PL - PR;
const IDEAL_SLOT = 24; // px/Periode, entspricht dem Slot, der auf Handy-Breite gut aussah
const maxPeriods = Math.min(28, Math.max(10, Math.round(cW / IDEAL_SLOT)));
const periods = allPeriods.slice(-maxPeriods);
const n = periods.length;
const cH = H - PT - PB;
const slot = cW / n;
const barW = Math.max(3, Math.min(26, slot * 0.36));
const barGap = Math.max(1.5, slot * 0.08);
const rBar = Math.min(2.5, barW / 2);
const allVals = periods.flatMap(d => [d.fixed_eur, d.spot_eur].filter(v => v != null));
const maxVal = Math.max(...allVals, 0.01);
const yTicks = 4;
let gridLines = '', yLabels = '';
for (let i = 0; i <= yTicks; i++) {
const v = maxVal * i / yTicks;
const y = PT + cH - (cH * i / yTicks);
gridLines += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="${C.grid}" stroke-width="1" stroke-dasharray="2,3"/>`;
yLabels += `<text x="${PL-6}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="9" fill="#8b949e">${fEur(v)}</text>`;
}
const gradId = isExample ? 'Ex' : '';
let bars = '', xLabels = '';
periods.forEach((d, i) => {
const cx = PL + i * slot + slot / 2;
const hFixed = Math.max(0, cH * d.fixed_eur / maxVal);
bars += `<rect x="${(cx - barGap/2 - barW).toFixed(1)}" y="${(PT + cH - hFixed).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hFixed,1).toFixed(1)}" rx="${rBar}" fill="url(#gradFixed${gradId})"/>`;
if (d.spot_eur != null) {
const hSpot = Math.max(0, cH * d.spot_eur / maxVal);
bars += `<rect x="${(cx + barGap/2).toFixed(1)}" y="${(PT + cH - hSpot).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hSpot,1).toFixed(1)}" rx="${rBar}" fill="url(#gradSpot${gradId})"/>`;
}
const ly = H - PB + 12;
xLabels += `<text x="${cx.toFixed(1)}" y="${ly}" text-anchor="end" font-size="8.5" fill="#8b949e" transform="rotate(-40 ${cx.toFixed(1)} ${ly})">${d.label}</text>`;
});
const defs = `<defs>
<linearGradient id="gradFixed${gradId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${C.fixed}" stop-opacity="0.95"/>
<stop offset="100%" stop-color="${C.fixed}" stop-opacity="0.55"/>
</linearGradient>
<linearGradient id="gradSpot${gradId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${C.spot}" stop-opacity="0.95"/>
<stop offset="100%" stop-color="${C.spot}" stop-opacity="0.55"/>
</linearGradient>
</defs>`;
const bucketLabel = bucket === 1 ? 'Tag' : 'Woche';
const rangeNote = allPeriods.length > n ? ` · letzte ${n}` : '';
const legend = `<div style="display:flex;gap:18px;margin-top:10px;font-size:11px;color:var(--text-dim)">
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.fixed};display:inline-block"></span>Festpreis</span>
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.spot};display:inline-block"></span>Spot (hypothetisch)</span>
</div>`;
return `
<div style="position:relative;border-top:1px solid var(--border);padding-top:24px;margin-top:4px">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:14px">Kosten je ${bucketLabel}${rangeNote}</div>
<svg width="${W}" height="${H}" style="display:block">
${defs}${gridLines}${yLabels}${bars}${xLabels}
</svg>
${legend}
${isExample ? `<div style="position:absolute;top:20px;right:0;font-size:10px;font-weight:700;letter-spacing:.08em;color:var(--text-dim);background:var(--surface2);border:1px solid var(--border);border-radius:20px;padding:3px 10px">BEISPIEL</div>` : ''}
</div>`;
}
async function loadFinance() {
const el = document.getElementById("finance-content");
let data;
@@ -746,10 +862,14 @@ async function loadFinance() {
const { days, fixed_total_eur, spot_total_eur, savings_eur, period_start, spot_days, total_days } = data;
if (!days || days.length === 0) {
el.innerHTML = `<div style="text-align:center;padding:40px;color:var(--text-dim)">
<div style="font-size:32px;margin-bottom:12px">📊</div>
<div style="font-weight:600;margin-bottom:6px">Noch keine Daten</div>
<div style="font-size:13px">Der Tracker sammelt ab morgen täglich Daten.<br>Abrechnungsjahr ab ${period_start}.</div>
const exampleChart = buildCostChart(exampleFinanceDays(14), el, true);
el.innerHTML = `<div style="padding:4px 0">
<div style="text-align:center;padding:28px 20px;color:var(--text-dim);border:1px dashed var(--border);border-radius:var(--radius);margin-bottom:24px">
<div style="font-size:28px;margin-bottom:10px">📊</div>
<div style="font-weight:600;margin-bottom:6px;color:var(--text)">Noch keine echten Daten</div>
<div style="font-size:13px">Der Tracker sammelt ab morgen täglich Daten.<br>Abrechnungsjahr ab ${period_start}. Unten siehst du, wie die Ansicht dann aussieht.</div>
</div>
${exampleChart}
</div>`;
return;
}
@@ -796,93 +916,7 @@ async function loadFinance() {
${spotCard}${savCard}
</div>`;
// ── SVG-Balkendiagramm ────────────────────────────────────────
// Bei langen Abrechnungsjahren nicht jeden einzelnen Tag zeigen — statt 112
// Tagesbalken lieber Wochenbalken, und davon nur die letzten 14 (reicht für
// einen Trend, bleibt aber lesbar statt endlos zu scrollen).
const rawN = days.length;
const bucket = rawN > 180 ? 14 : rawN > 21 ? 7 : 1;
const allPeriods = bucket === 1 ? days.map(d => ({
fixed_eur: d.fixed_eur, spot_eur: d.spot_eur, label: d.date.slice(5),
})) : (() => {
const out = [];
for (let i = 0; i < days.length; i += bucket) {
const chunk = days.slice(i, i + bucket);
const fixed = chunk.reduce((s, d) => s + (d.fixed_eur || 0), 0);
const spotVals = chunk.map(d => d.spot_eur).filter(v => v != null);
const spot = spotVals.length ? spotVals.reduce((a, b) => a + b, 0) : null;
out.push({ fixed_eur: fixed, spot_eur: spot, label: chunk[0].date.slice(5) });
}
return out;
})();
const MAX_PERIODS = 14;
const periods = allPeriods.slice(-MAX_PERIODS);
// Echtes responsives SVG: viewBox in einer "Mobile-first"-Breite, skaliert per
// width:100% frei auf den Container (Handy wie PC) — kein Scrollen mehr nötig,
// da wir uns auf max. 14 Balken begrenzen.
const n = periods.length;
const W = 380, H = 220, PL = 40, PR = 8, PT = 14, PB = 46;
const cW = W - PL - PR;
const cH = H - PT - PB;
const slot = cW / n;
const barW = Math.max(3, slot * 0.36);
const barGap = Math.max(1.5, slot * 0.08);
const rBar = Math.min(2.5, barW / 2);
const allVals = periods.flatMap(d => [d.fixed_eur, d.spot_eur].filter(v => v != null));
const maxVal = Math.max(...allVals, 0.01);
const yTicks = 4;
let gridLines = '', yLabels = '';
for (let i = 0; i <= yTicks; i++) {
const v = maxVal * i / yTicks;
const y = PT + cH - (cH * i / yTicks);
gridLines += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="${C.grid}" stroke-width="1" stroke-dasharray="2,3"/>`;
yLabels += `<text x="${PL-6}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="9" fill="#8b949e">${fEur(v)}</text>`;
}
// Labels schräg statt horizontal — braucht bei 14 Balken pro Label deutlich
// weniger Breite, dadurch bleiben trotz Platzmangel alle 14 lesbar.
let bars = '', xLabels = '';
periods.forEach((d, i) => {
const cx = PL + i * slot + slot / 2;
const hFixed = Math.max(0, cH * d.fixed_eur / maxVal);
bars += `<rect x="${(cx - barGap/2 - barW).toFixed(1)}" y="${(PT + cH - hFixed).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hFixed,1).toFixed(1)}" rx="${rBar}" fill="url(#gradFixed)"/>`;
if (d.spot_eur != null) {
const hSpot = Math.max(0, cH * d.spot_eur / maxVal);
bars += `<rect x="${(cx + barGap/2).toFixed(1)}" y="${(PT + cH - hSpot).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hSpot,1).toFixed(1)}" rx="${rBar}" fill="url(#gradSpot)"/>`;
}
const ly = H - PB + 12;
xLabels += `<text x="${cx.toFixed(1)}" y="${ly}" text-anchor="end" font-size="8.5" fill="#8b949e" transform="rotate(-40 ${cx.toFixed(1)} ${ly})">${d.label}</text>`;
});
const defs = `<defs>
<linearGradient id="gradFixed" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${C.fixed}" stop-opacity="0.95"/>
<stop offset="100%" stop-color="${C.fixed}" stop-opacity="0.55"/>
</linearGradient>
<linearGradient id="gradSpot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${C.spot}" stop-opacity="0.95"/>
<stop offset="100%" stop-color="${C.spot}" stop-opacity="0.55"/>
</linearGradient>
</defs>`;
const bucketLabel = bucket === 1 ? 'Tag' : bucket === 7 ? 'Woche' : '2 Wochen';
const rangeNote = allPeriods.length > MAX_PERIODS ? ` · letzte ${n}` : '';
const legend = `<div style="display:flex;gap:18px;margin-top:10px;font-size:11px;color:var(--text-dim)">
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.fixed};display:inline-block"></span>Festpreis</span>
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.spot};display:inline-block"></span>Spot (hypothetisch)</span>
</div>`;
const chartSection = `
<div style="border-top:1px solid var(--border);padding-top:24px;margin-top:4px">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:14px">Kosten je ${bucketLabel}${rangeNote}</div>
<svg viewBox="0 0 ${W} ${H}" style="width:100%;max-width:640px;height:auto;display:block">
${defs}${gridLines}${yLabels}${bars}${xLabels}
</svg>
${legend}
</div>`;
const chartSection = buildCostChart(days, el, false);
el.innerHTML = `<div style="padding:4px 0">${empfehlung}${cards}${chartSection}</div>`;
} catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; console.error('loadFinance:', e); }