Feature: Analytics 30-Tage Dual-Chart, Referrers, Umami-Credentials — SW by-v489, APP_VER 466

This commit is contained in:
rene 2026-04-29 11:06:18 +02:00
parent a4da7144d6
commit e22dcc3c3d
4 changed files with 154 additions and 90 deletions

View file

@ -218,95 +218,149 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
// TAB: ANALYTICS
async function _renderAnalytics(el) {
const d = await API.get('/admin/analytics');
const pv = d.pageviews?.pageviews ?? [];
const ses = d.pageviews?.sessions ?? [];
// Sparkline SVG (Seitenaufrufe 7 Tage)
function _sparkline(data, color) {
if (!data.length) return '<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Keine Daten</span>';
const vals = data.map(p => p.y ?? 0);
const max = Math.max(...vals, 1);
const W = 200, H = 48, pad = 4;
const pts = vals.map((v, i) => {
const x = pad + i * ((W - 2*pad) / Math.max(vals.length - 1, 1));
const y = H - pad - (v / max) * (H - 2*pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:48px">
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"/>
</svg>`;
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade Analytics…</div>`;
let d;
try { d = await API.get('/admin/analytics'); }
catch (err) {
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger);font-size:var(--text-sm)">
${UI.icon('warning')} Fehler: ${_esc(err.message || String(err))}</div>`;
return;
}
// Umami v2 liefert plain numbers, v1 liefert {value: X} — beide abfangen
const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0);
const fmt = v => Number(v).toLocaleString('de');
// Bounce Rate & Verweildauer
const _bounces = tv(d.today?.bounces);
const _visits = tv(d.today?.visits);
const bounceToday = _visits > 0
? ((_bounces / _visits) * 100).toFixed(0) + ' %'
: '—';
const _totaltime = tv(d.week?.totaltime);
const _visitsW = tv(d.week?.visits);
const timeWeek = _totaltime > 0 && _visitsW > 0
? Math.round(_totaltime / _visitsW) + ' s'
: '—';
const pv = d.pageviews?.pageviews ?? [];
const ses = d.pageviews?.sessions ?? [];
// Bounce + Verweildauer
const _bounces = tv(d.today?.bounces), _vis = tv(d.today?.visits);
const bounceToday = _vis > 0 ? ((_bounces / _vis) * 100).toFixed(0) + ' %' : '—';
const _tt = tv(d.week?.totaltime), _vw = tv(d.week?.visits);
const timeWeek = _tt > 0 && _vw > 0 ? Math.round(_tt / _vw) + ' s' : '—';
// Dual-Series Area+Line Chart (SVG)
function _dualChart(pvData, sesData) {
if (!pvData.length) return `<p style="color:var(--c-text-muted);font-size:var(--text-xs);margin:0">Keine Daten</p>`;
const W = 800, H = 120, padX = 0, padY = 8;
const pvVals = pvData.map(p => p.y ?? 0);
const sesVals = sesData.map(p => p.y ?? 0);
const maxVal = Math.max(...pvVals, ...sesVals, 1);
const n = pvData.length;
function toXY(vals) {
return vals.map((v, i) => {
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
const y = H - padY - (v / maxVal) * (H - 2*padY);
return [x.toFixed(1), y.toFixed(1)];
});
}
const pvPts = toXY(pvVals);
const sesPts = toXY(sesVals);
const pvLine = pvPts.map(p => p.join(',')).join(' ');
const sesLine = sesPts.map(p => p.join(',')).join(' ');
// Filled area unter pageviews
const areaPath = `M ${pvPts[0][0]},${H} ` +
pvPts.map(p => `L ${p[0]},${p[1]}`).join(' ') +
` L ${pvPts[pvPts.length-1][0]},${H} Z`;
// X-Achsen-Labels: Anfang, Mitte, Ende
const labelIdx = [0, Math.floor(n/2), n-1].filter((v,i,a) => a.indexOf(v)===i);
const labels = labelIdx.map(i => {
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
const date = pvData[i]?.x ? new Date(pvData[i].x).toLocaleDateString('de',{day:'2-digit',month:'2-digit'}) : '';
return `<text x="${x.toFixed(0)}" y="${H+14}" text-anchor="middle"
font-size="10" fill="currentColor" style="color:var(--c-text-muted)">${date}</text>`;
}).join('');
return `<svg viewBox="0 0 ${W} ${H+18}" style="width:100%;height:100px;display:block;overflow:visible"
preserveAspectRatio="none">
<defs>
<linearGradient id="pvGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity="0.25"/>
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0.02"/>
</linearGradient>
</defs>
<path d="${areaPath}" fill="url(#pvGrad)"/>
<polyline points="${pvLine}" fill="none" stroke="var(--c-primary)"
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<polyline points="${sesLine}" fill="none" stroke="var(--c-success)"
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="4,2"/>
${labels}
</svg>`;
}
// Balken-Chart für Top-Pages / Referrers
function _barChart(items, labelKey = 'x', valKey = 'y') {
if (!items?.length) return `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Daten</p>`;
const maxV = Math.max(...items.map(p => p[valKey] ?? 0), 1);
return `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${items.map(p => {
const pct = ((p[valKey] ?? 0) / maxV * 100).toFixed(0);
return `<div>
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs);margin-bottom:3px">
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:78%">${_esc(p[labelKey] || '—')}</span>
<span style="color:var(--c-text-secondary);flex-shrink:0;margin-left:var(--space-2)">${fmt(p[valKey] ?? 0)}</span>
</div>
<div style="height:4px;border-radius:2px;background:var(--c-surface-2)">
<div style="height:100%;width:${pct}%;border-radius:2px;background:var(--c-primary);transition:width .4s"></div>
</div>
</div>`;
}).join('')}
</div>`;
}
el.innerHTML = `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
${_statCard('users','Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
${_statCard('arrow-u-up-left','Bounce heute', bounceToday, 'var(--c-text-secondary)')}
${_statCard('timer','Ø Verweildauer 7 Tage', timeWeek, 'var(--c-text-secondary)')}
<!-- Stat-Karten -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:var(--space-3)">
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
${_statCard('users', 'Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
${_statCard('users', 'Besucher 30 Tage', fmt(tv(d.month?.visitors)), 'var(--c-text-secondary)')}
${_statCard('eye', 'Aufrufe 30 Tage', fmt(tv(d.month?.pageviews)), 'var(--c-text-secondary)')}
${_statCard('arrow-u-up-left', 'Bounce heute', bounceToday, 'var(--c-text-secondary)')}
${_statCard('timer', 'Ø Verweildauer 7d', timeWeek, 'var(--c-text-secondary)')}
</div>
<!-- Verlaufschart 30 Tage -->
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Seitenaufrufe letzte 7 Tage</div>
${_sparkline(pv, 'var(--c-primary)')}
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
${pv.map(p => `<span>${new Date(p.x).toLocaleDateString('de',{weekday:'short'})}</span>`).join('')}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Verlauf letzte 30 Tage</span>
<div style="display:flex;gap:var(--space-3);font-size:10px;color:var(--c-text-muted)">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:2px;background:var(--c-primary);display:inline-block;border-radius:1px"></span> Aufrufe
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:2px;background:var(--c-success);display:inline-block;border-radius:1px;
border-bottom:2px dashed var(--c-success);background:none"></span> Besucher
</span>
</div>
</div>
${_dualChart(pv, ses)}
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Top Seiten letzte 7 Tage</div>
${(d.top_pages ?? []).length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Daten</p>`
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${d.top_pages.map(p => {
const maxY = d.top_pages[0].y;
const pct = maxY > 0 ? (p.y / maxY * 100).toFixed(0) : 0;
return `
<div>
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);margin-bottom:3px">
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;max-width:75%">${UI.escape(p.x)}</span>
<span style="color:var(--c-text-secondary);flex-shrink:0">${fmt(p.y)}</span>
</div>
<div style="height:4px;border-radius:2px;background:var(--c-surface-3)">
<div style="height:100%;width:${pct}%;border-radius:2px;
background:var(--c-primary);transition:width .3s"></div>
</div>
</div>`;
}).join('')}
</div>`}
<!-- Top Seiten + Referrers nebeneinander -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4)">
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-3)">Top Seiten 30 Tage</div>
${_barChart(d.top_pages)}
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-3)">Referrers 30 Tage</div>
${_barChart(d.referrers)}
</div>
</div>
</div>`;
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------