Feature: Analytics 30-Tage Dual-Chart, Referrers, Umami-Credentials — SW by-v489, APP_VER 466
This commit is contained in:
parent
a4da7144d6
commit
e22dcc3c3d
4 changed files with 154 additions and 90 deletions
|
|
@ -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
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue