"use strict"; // Statistik-Tab für das Verwaltungsportal. Selbst-injizierend nach dem // Muster von rechner.js, damit dashboard.html unverändert bleibt. // Charts sind handgerendertes Inline-SVG ohne Bibliothek. Die Serienfarben // sind mit dem dataviz-Palette-Validator gegen beide Themes geprüft // (Light #96791A/#2a78d6/#199e70, Dark #a8863a/#3987e5/#199e70, alle Checks PASS). (function () { var geladen = false; var aktMonate = 12; // ---------- Format-Helfer ---------- function fmt(n) { return Number(n || 0).toLocaleString("de-DE"); } function fmtUsd(n) { return "$" + Number(n || 0).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function fmtMonat(ym) { if (!ym) return ""; var namen = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; return namen[parseInt(ym.slice(5, 7), 10) - 1] + " " + ym.slice(2, 4); } function fmtDatum(s) { if (!s) return "-"; var d = String(s).slice(0, 10).split("-"); return d.length === 3 ? d[2] + "." + d[1] + "." + d[0] : s; } function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } // ---------- SVG-Renderer ---------- // Eine Achse pro Chart, rezessives Grid, 2px-Lücken zwischen Stapel- // Segmenten, Hover über grosszügige unsichtbare Trefferflächen mit . var W = 720, H = 230, ML = 52, MR = 8, MT = 10, MB = 26; function niceMax(v) { if (v <= 0) return 1; var mag = Math.pow(10, Math.floor(Math.log10(v))); var norm = v / mag; var step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10; return step * mag; } function achsen(maxY, einheit) { var plotH = H - MT - MB; var out = ""; for (var i = 0; i <= 3; i++) { var y = MT + plotH - (plotH * i) / 3; var wert = (maxY * i) / 3; out += '<line x1="' + ML + '" y1="' + y + '" x2="' + (W - MR) + '" y2="' + y + '" stroke="var(--border)" stroke-width="1"/>'; out += '<text x="' + (ML - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="10" fill="var(--text-muted)">' + esc(kurz(wert, einheit)) + "</text>"; } return out; } function kurz(v, einheit) { var s; if (v >= 1000000) s = (v / 1000000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " Mio"; else if (v >= 10000) s = Math.round(v / 1000).toLocaleString("de-DE") + "k"; else s = v.toLocaleString("de-DE", { maximumFractionDigits: v < 10 ? 1 : 0 }); return einheit === "usd" ? s + " $" : s; } function xLabels(labels) { var n = labels.length; if (!n) return ""; var plotW = W - ML - MR; var schritt = n > 8 ? 2 : 1; var out = ""; for (var i = 0; i < n; i++) { if (i % schritt !== 0) continue; var x = ML + (plotW * (i + 0.5)) / n; out += '<text x="' + x + '" y="' + (H - 8) + '" text-anchor="middle" font-size="10" fill="var(--text-muted)">' + esc(labels[i]) + "</text>"; } return out; } // serien: [{name, farbe, werte[]}], gestapelt. einheit: 'usd' | '' function chartBalken(labels, serien, einheit) { var n = labels.length; if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>'; var summen = labels.map(function (_, i) { return serien.reduce(function (a, s) { return a + (s.werte[i] || 0); }, 0); }); var maxY = niceMax(Math.max.apply(null, summen.concat([0]))); var plotW = W - ML - MR, plotH = H - MT - MB; var breite = Math.min(40, (plotW / n) * 0.62); var svg = achsen(maxY, einheit); for (var i = 0; i < n; i++) { var x = ML + (plotW * (i + 0.5)) / n - breite / 2; var yCursor = MT + plotH; var titel = [fmtMonat(labels[i]) || labels[i]]; for (var s = 0; s < serien.length; s++) { var wert = serien[s].werte[i] || 0; titel.push(serien[s].name + ": " + (einheit === "usd" ? fmtUsd(wert) : fmt(wert))); if (wert <= 0) continue; var hoehe = (wert / maxY) * plotH; yCursor -= hoehe; // 2px-Lücke zwischen Segmenten, oberstes Segment leicht gerundet var segH = Math.max(0, hoehe - 2); if (segH > 0) { svg += '<rect x="' + x + '" y="' + yCursor + '" width="' + breite + '" height="' + segH + '" rx="2" fill="' + serien[s].farbe + '"/>'; } } if (summen[i] <= 0) titel.push("Keine Daten"); svg += '<rect x="' + (ML + (plotW * i) / n) + '" y="' + MT + '" width="' + (plotW / n) + '" height="' + plotH + '" fill="transparent"><title>' + esc(titel.join("\n")) + ""; } svg += xLabels(labels.map(fmtMonat)); return '' + svg + ""; } // serien: [{name, farbe, werte[]}] mit null-Lücken. xIstTag = Tageslabels function chartLinie(labels, serien, einheit, xIstTag) { var n = labels.length; if (!n) return '
Keine Daten im Zeitraum
'; var alle = []; serien.forEach(function (s) { s.werte.forEach(function (v) { if (v != null) alle.push(v); }); }); if (!alle.length) return '
Keine Daten im Zeitraum
'; var maxY = niceMax(Math.max.apply(null, alle)); var plotW = W - ML - MR, plotH = H - MT - MB; function px(i) { return ML + (plotW * (i + 0.5)) / n; } function py(v) { return MT + plotH - (v / maxY) * plotH; } var svg = achsen(maxY, einheit); serien.forEach(function (s) { var punkte = []; for (var i = 0; i < n; i++) { if (s.werte[i] == null) continue; punkte.push(px(i).toFixed(1) + "," + py(s.werte[i]).toFixed(1)); } if (punkte.length > 1) { svg += ''; } for (var j = 0; j < n; j++) { if (s.werte[j] == null) continue; svg += ''; svg += '' + esc((xIstTag ? fmtDatum(labels[j]) : fmtMonat(labels[j])) + "\n" + s.name + ": " + (einheit === "usd" ? fmtUsd(s.werte[j]) : fmt(s.werte[j]))) + ""; } }); svg += xLabels(xIstTag ? labels.map(function (t, i) { return i % Math.ceil(n / 8) === 0 ? String(t).slice(8, 10) + "." + String(t).slice(5, 7) + "." : ""; }) : labels.map(fmtMonat)); return '' + svg + ""; } function legende(eintraege) { return '
' + eintraege.map(function (e) { return '' + esc(e.name) + ""; }).join("") + "
"; } function kpiKachel(label, wert, sub) { return '
' + esc(label) + '
' + '
' + wert + "
" + (sub ? '
' + sub + "
" : "") + "
"; } function chartCard(titel, inhalt, legendeHtml) { return '

' + esc(titel) + "

" + (legendeHtml || "") + '
' + inhalt + "
"; } // ---------- Rendering ---------- function C1() { return "var(--st-c1)"; } function C2() { return "var(--st-c2)"; } function C3() { return "var(--st-c3)"; } function render(d) { var el = document.getElementById("st-inhalt"); if (!el) return; var monate = d.window.monate || []; var mt = {}; (d.monat_typ || []).forEach(function (r) { mt[r.monat + "|" + r.typ] = r; }); function reihe(typ, feld) { return monate.map(function (m) { var r = mt[m + "|" + typ]; return r ? r[feld] : 0; }); } function reiheNull(typ, feld) { return monate.map(function (m) { var r = mt[m + "|" + typ]; return r && r[feld] != null ? r[feld] : null; }); } // Übrige Quellen (globe/chat/enhance) je Monat zusammenfassen var nebenKosten = {}; (d.monat_quelle || []).forEach(function (r) { if (r.quelle === "monitor") return; nebenKosten[r.monat] = (nebenKosten[r.monat] || 0) + (r.kosten || 0); }); var kpi = d.kpi || {}; var html = ""; // --- Kosten & Marge --- html += '

Kosten & Marge

'; html += '
' + kpiKachel("API-Kosten im Zeitraum", fmtUsd(kpi.kosten_usd), fmt(kpi.laeufe_gesamt) + " Läufe gesamt") + kpiKachel("Erfolgreiche Läufe", fmt(kpi.laeufe_ok), kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "% Erfolgsquote" : "") + kpiKachel("Median je Lauf", kpi.median_kosten_lauf != null ? fmtUsd(kpi.median_kosten_lauf) : "-", "nur Läufe mit Kosten") + kpiKachel("Artikel gefunden", fmt(kpi.artikel), fmt(kpi.tokens_in) + " Tokens ein / " + fmt(kpi.tokens_out) + " aus") + "
"; html += chartCard("API-Kosten je Monat (USD)", chartBalken(monate, [ { name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "kosten") }, { name: "Recherche", farbe: C2(), werte: reihe("research", "kosten") }, { name: "Übrige Quellen", farbe: C3(), werte: monate.map(function (m) { return Math.round((nebenKosten[m] || 0) * 100) / 100; }) }, ], "usd"), legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }, { name: "Übrige Quellen", farbe: C3() }])); html += chartCard("Median-Kosten je Lauf (USD), der Frühwarner für Kosten-Anstieg", chartLinie(monate, [ { name: "Live-Monitoring", farbe: C1(), werte: reiheNull("adhoc", "kosten_median") }, { name: "Recherche", farbe: C2(), werte: reiheNull("research", "kosten_median") }, ], "usd"), legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }])); // Orgs-Tabelle var orgZeilen = (d.orgs || []).map(function (o) { var margeCls = o.marge_usd >= 0 ? "st-pos" : "st-neg"; return "" + esc(o.name) + "" + fmt(o.laeufe_ok) + " / " + fmt(o.laeufe_gesamt) + "" + "" + fmtUsd(o.kosten_usd) + "" + fmt(o.credits_geschaetzt) + "" + "" + fmtUsd(o.gegenwert_usd) + '' + fmtUsd(o.marge_usd) + ""; }).join(""); html += '

Je Organisation (Zeitraum)

' + '
' + "" + "" + (orgZeilen || '') + "
OrganisationLäufe ok/gesamtIst-KostenCredits (geschätzt)GegenwertMarge
Keine Daten
" + '
Schätzung zu den heutigen Sätzen (billing_tariff), es gibt keine historisierten Buchungen. ' + "Recherche-Anlage zählt als 3 Durchläufe (erster abgeschlossener Lauf je Recherche-Lage). " + "Studio-Bausteine (analysis/factcheck) fehlen mangels Nutzungsdaten, der Gegenwert ist eher zu niedrig. " + "Ist-Kosten enthalten auch Fehlläufe. 1 Credit = " + fmtUsd(d.tarif.usd_per_credit) + ".
"; // --- Nutzung --- html += '

Nutzung

'; var mb = {}; (d.monat_betrieb || []).forEach(function (r) { mb[r.monat] = r; }); function bReihe(feld) { return monate.map(function (m) { return mb[m] ? mb[m][feld] : 0; }); } html += chartCard("Erfolgreiche Läufe je Monat", chartBalken(monate, [ { name: "Automatisch", farbe: C1(), werte: bReihe("auto") }, { name: "Manuell", farbe: C2(), werte: bReihe("manual") }, ], ""), legende([{ name: "Automatisch", farbe: C1() }, { name: "Manuell", farbe: C2() }])); html += chartCard("Gefundene Artikel je Monat", chartBalken(monate, [ { name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "artikel") }, { name: "Recherche", farbe: C2(), werte: reihe("research", "artikel") }, ], ""), legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }])); // --- Betrieb --- html += '

Betrieb

'; var dauerA = d.dauer_typ && d.dauer_typ.adhoc != null ? Math.round(d.dauer_typ.adhoc / 60) + " Min" : "-"; var dauerR = d.dauer_typ && d.dauer_typ.research != null ? Math.round(d.dauer_typ.research / 60) + " Min" : "-"; html += '
' + kpiKachel("Erfolgsquote", kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "%" : "-", fmt(kpi.laeufe_ok) + " von " + fmt(kpi.laeufe_gesamt) + " Läufen") + kpiKachel("Dauer Live-Lauf (ø)", dauerA, "") + kpiKachel("Dauer Recherche-Durchlauf (ø)", dauerR, "") + "
"; html += chartCard("Läufe nach Ausgang je Monat", chartBalken(monate, [ { name: "Erfolgreich", farbe: "var(--success)", werte: bReihe("completed") }, { name: "Fehler", farbe: "var(--danger)", werte: bReihe("error") }, { name: "Abgebrochen", farbe: "var(--text-muted)", werte: bReihe("cancelled") }, ], ""), legende([{ name: "Erfolgreich", farbe: "var(--success)" }, { name: "Fehler", farbe: "var(--danger)" }, { name: "Abgebrochen", farbe: "var(--text-muted)" }])); var fehlerZeilen = (d.fehler_top || []).map(function (f) { return "" + esc(f.grund) + "" + fmt(f.n) + ""; }).join(""); html += '

Häufigste Fehlerursachen

' + '
' + (fehlerZeilen || '') + "
UrsacheAnzahl
Keine Fehler im Zeitraum
"; // --- Aktivität --- html += '

Aktivität

'; var akt = d.aktivitaet || {}; html += '
' + kpiKachel("Nutzer mit Login (30 Tage)", fmt(akt.nutzer_30d), "Momentaufnahme aus dem letzten Login") + kpiKachel("Aktivitäts-Erfassung", akt.erfassung_seit ? "seit " + fmtDatum(akt.erfassung_seit) : "startet jetzt", akt.erfassung_seit ? "je Nutzer und Tag ein Eintrag" : "MAU/DAU wachsen ab diesem Update") + "
"; var mauWerte = (akt.mau || []).map(function (r) { return r.nutzer; }); var mauMax = Math.max.apply(null, mauWerte.concat([0])); if (mauMax > 0) { html += chartCard("Aktive Nutzer je Monat (MAU)", chartBalken(monate, [{ name: "Aktive Nutzer", farbe: C1(), werte: mauWerte }], ""), ""); var dauLabels = (akt.dau || []).map(function (r) { return r.tag; }); var dauWerte = (akt.dau || []).map(function (r) { return r.nutzer; }); html += chartCard("Aktive Nutzer je Tag (DAU, letzte 30 Tage)", dauLabels.length ? chartLinie(dauLabels, [{ name: "Aktive Nutzer", farbe: C1(), werte: dauWerte }], "", true) : '
Noch keine Tageswerte
', ""); } else { html += '
Noch keine Aktivitätsdaten. ' + "Die Erfassung (ein Eintrag je Nutzer und Tag) beginnt mit diesem Update, " + "die MAU/DAU-Kurven erscheinen hier, sobald sich Nutzer im Monitor bewegen.
"; } // --- Wachstum --- html += '

Wachstum

'; var w = {}; (d.wachstum || []).forEach(function (r) { w[r.monat] = r; }); function wReihe(feld) { return monate.map(function (m) { return w[m] ? w[m][feld] : 0; }); } html += chartCard("Neuzugänge je Monat", chartBalken(monate, [ { name: "Organisationen", farbe: C1(), werte: wReihe("orgs") }, { name: "Nutzer", farbe: C2(), werte: wReihe("users") }, { name: "Lagen", farbe: C3(), werte: wReihe("lagen") }, ], ""), legende([{ name: "Organisationen", farbe: C1() }, { name: "Nutzer", farbe: C2() }, { name: "Lagen", farbe: C3() }])); el.innerHTML = html; // Datenstand var ds = document.getElementById("st-datenstand"); if (ds) { var teile = []; if (d.datenstand.letzter_lauf) teile.push("Letzter Lauf " + fmtDatum(d.datenstand.letzter_lauf)); teile.push("Kostenerfassung ab 03/2026"); if (d.tarif && d.tarif.quelle === "defaults") teile.push("Sätze aus Rückfallwerten"); ds.textContent = "Datenstand: " + teile.join(" · "); } } // ---------- Laden ---------- function lade() { var el = document.getElementById("st-inhalt"); if (el) el.innerHTML = '
Lade Statistik …
'; API.get("/api/statistik?months=" + aktMonate).then(function (d) { render(d); }).catch(function (err) { if (el) el.innerHTML = '
Statistik konnte nicht geladen werden: ' + esc(err.message) + "
"; }); } function setzeZeitraum(monate, btn) { aktMonate = monate; document.querySelectorAll("#st-zeitraum button").forEach(function (b) { b.classList.remove("btn-primary"); b.classList.add("btn-secondary"); }); btn.classList.remove("btn-secondary"); btn.classList.add("btn-primary"); lade(); } // ---------- Injektion (Muster rechner.js) ---------- var STYLES = "\n" + "#sec-statistik { --st-c1:#a8863a; --st-c2:#3987e5; --st-c3:#199e70; }\n" + '[data-theme="light"] #sec-statistik { --st-c1:#96791A; --st-c2:#2a78d6; --st-c3:#199e70; }\n' + "#sec-statistik .st-gruppe { margin:26px 0 12px; font-size:16px; border-bottom:1px solid var(--border); padding-bottom:6px; }\n" + "#sec-statistik .st-kopf { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }\n" + "#sec-statistik .st-kopf h2 { font-size:20px; margin-right:auto; }\n" + "#sec-statistik .st-datenstand { font-size:12px; color:var(--text-muted); margin-top:6px; }\n" + "#sec-statistik .st-legende { display:flex; gap:14px; font-size:12px; color:var(--text-secondary); flex-wrap:wrap; }\n" + "#sec-statistik .st-dot { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:5px; vertical-align:-1px; }\n" + "#sec-statistik .st-leer { padding:22px; text-align:center; color:var(--text-muted); font-size:13px; }\n" + "#sec-statistik .st-fussnote { font-size:12px; color:var(--text-muted); border-top:1px solid var(--border); }\n" + "#sec-statistik .st-pos { color:var(--success); }\n" + "#sec-statistik .st-neg { color:var(--danger); }\n" + "#sec-statistik .card { margin-bottom:16px; }\n"; function init() { var nav = document.querySelector("nav.nav-tabs"); var content = document.querySelector(".app-content"); if (!nav || !content || document.getElementById("sec-statistik")) return; var style = document.createElement("style"); style.textContent = STYLES; document.head.appendChild(style); var sektion = document.createElement("div"); sektion.className = "section"; sektion.id = "sec-statistik"; sektion.innerHTML = '
' + "

Statistik

" + '
' + '' + '' + '' + "
" + "
" + '
' + '
'; content.appendChild(sektion); var btn = document.createElement("button"); btn.className = "nav-tab"; btn.setAttribute("data-section", "statistik"); btn.innerHTML = 'Statistik'; // Zwischen Audit-Log und Verbrauchsrechner, egal in welcher Ladereihenfolge var rechnerTab = nav.querySelector('.nav-tab[data-section="rechner"]'); nav.insertBefore(btn, rechnerTab || null); btn.addEventListener("click", function () { document.querySelectorAll("nav.nav-tabs .nav-tab").forEach(function (t) { t.classList.remove("active"); }); btn.classList.add("active"); document.querySelectorAll(".app-content > .section").forEach(function (s) { s.classList.remove("active"); }); var detail = document.getElementById("orgDetail"); if (detail) detail.classList.remove("active"); sektion.classList.add("active"); if (!geladen) { geladen = true; lade(); } }); nav.addEventListener("click", function (e) { var ziel = e.target.closest(".nav-tab"); if (!ziel || ziel === btn) return; btn.classList.remove("active"); sektion.classList.remove("active"); }); sektion.querySelectorAll("#st-zeitraum button").forEach(function (b) { b.addEventListener("click", function () { setzeZeitraum(parseInt(b.getAttribute("data-monate"), 10), b); }); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();