Website-Statistik: eigene Seite für die Analyse von webklar.com

Neue Mitarbeiterseite /statistik (für alle Mitarbeiter, Eintrag nach "Rechtliches"):
Kennzahlen mit Vergleich zur Vorperiode, Verlauf pro Tag (uPlot), Ranglisten für
Seiten, Herkunft, Länder, Geräte und Kontakt-Klicks, Banner-Zahlen und Heatmap-Link
(neuer Tab über openInNewTab). Anzeige-Logik als reine Helfer mit Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 18:34:17 +00:00
1 parent f11fd0bb25
commit f9b0cbc855
12 files changed
+790 -2

No files matched your search

+1
View File
@@ -8,4 +8,5 @@ export const CHART = {
axis: '#5e6678',
grid: '#ebeef1',
tick: '#d8dde3',
second: '#2563eb', // zweite Reihe (Website-Statistik: Besucher neben den Seitenaufrufen)
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react'
import { Button, Field } from '../ui'
import { eigeneHeatmapLink } from '../../lib/eigeneAnalyseApi'
import { openInNewTab } from '../../lib/openInNewTab'
import { deviceLabel, pageLabel } from '../../lib/eigeneAnalyseView'
const VIEWPORTS = ['m', 't', 'd']
// Heatmap einer Seite von webklar.com: der Einmal-Link (30 Min.) oeffnet in einem neuen Tab. webklar.com laesst sich nicht
// einbetten (X-Frame-Options), deshalb gibt es keinen iframe; openInNewTab macht den Tab synchron im Klick auf.
export default function HeatmapBox({ pages }) {
const [picked, setPicked] = useState('')
const [busy, setBusy] = useState('')
const [error, setError] = useState('')
const page = pages.some((p) => p.key === picked) ? picked : pages[0]?.key || ''
const open = async (vp) => {
setBusy(vp)
setError('')
try {
await openInNewTab(async () => (await eigeneHeatmapLink(page, vp)).url)
} catch (err) {
setError(err.message || 'Die Heatmap konnte nicht geöffnet werden.')
} finally {
setBusy('')
}
}
return (
<div className="ws-heatmap" data-testid="ws-heatmap">
<h3 className="ws-list-title">Heatmap</h3>
<div className="ws-hm-controls">
<Field label="Seite" htmlFor="ws-hm-page">
<select id="ws-hm-page" className="select" value={page} disabled={pages.length === 0} onChange={(e) => setPicked(e.target.value)}>
{pages.length === 0 && <option value="">Noch keine Seiten</option>}
{pages.map((p) => <option key={p.key} value={p.key}>{pageLabel(p.key)}</option>)}
</select>
</Field>
<div className="ws-hm-buttons">
{VIEWPORTS.map((vp) => (
<Button key={vp} data-testid={`ws-hm-${vp}`} disabled={!page || busy !== ''} onClick={() => open(vp)}>
{busy === vp ? 'Öffnet …' : deviceLabel(vp)}
</Button>
))}
</div>
</div>
{error && <div className="msg err ws-hm-error" role="alert">{error}</div>}
<p className="muted small">
Die Heatmap enthält nur Klicks von Besuchern, die den Cookie-Banner angenommen haben. Sie öffnet sich in einem neuen Tab, der Link ist 30 Minuten gültig.
</p>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from 'react'
import { formatInt } from '../../lib/eigeneAnalyseView'
const LIMIT = 8
// Rangliste mit Zahl und Balken. rows kommt aus rankRows() (eigeneAnalyseView.js); ab mehr als LIMIT Zeilen klappt "Alle anzeigen" auf.
export default function RankList({ id, title, rows, hint }) {
const [all, setAll] = useState(false)
const shown = all ? rows : rows.slice(0, LIMIT)
return (
<div className="ws-list" data-testid={`ws-list-${id}`}>
<h3 className="ws-list-title">{title}</h3>
{hint && <div className="ws-list-hint muted small">{hint}</div>}
{rows.length === 0 ? (
<div className="muted">Keine Daten im Zeitraum.</div>
) : (
<ul className="ws-rows">
{shown.map((r) => (
<li key={r.key} className="ws-row">
<div className="ws-row-top">
<span className="ws-row-label">{r.label}</span>
<span className="ws-row-count num">
{formatInt(r.count)}
{r.percent !== null && <span className="ws-row-pct">{r.percent} %</span>}
</span>
</div>
<div className="ws-bar" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.round(r.bar * 100))}%` }} /></div>
</li>
))}
</ul>
)}
{rows.length > LIMIT && (
<button type="button" className="link ws-more" onClick={() => setAll((v) => !v)}>
{all ? 'Weniger anzeigen' : `Alle ${rows.length} anzeigen`}
</button>
)}
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef } from 'react'
import uPlot from 'uplot'
import { formatDay, formatDayShort, formatInt } from '../../lib/eigeneAnalyseView'
import { CHART } from '../serverpuls/chartColors'
import 'uplot/dist/uPlot.min.css'
// Ganze Zahlen auf der y-Achse (Zaehlwerte): keine Zwischenschritte wie 0,5
const INCRS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 2500, 5000, 10000, 25000, 50000, 100000, 1000000]
/**
* Liniendiagramm pro Tag (uPlot, Aufbau wie serverpuls/Chart.jsx).
* days: ['YYYY-MM-DD', ...]; series: [{ label, color, fill, values }] mit je einem Wert (oder null) pro Tag.
* Die x-Achse zaehlt die Tage durch (Index), Beschriftung und Legende lesen das Datum aus days: so gibt es keine Zeitzonenfehler.
*/
export default function TagesChart({ days, series, height = 280 }) {
const host = useRef(null)
const plot = useRef(null)
const daysRef = useRef(days)
daysRef.current = days
const seriesRef = useRef(series)
seriesRef.current = series
const toData = (d, s) => [d.map((_, i) => i), ...s.map((x) => x.values)]
useEffect(() => {
const el = host.current
const dayAt = (i) => daysRef.current[i]
const opts = {
width: Math.max(el.clientWidth, 200),
height,
padding: [null, 26, null, null], // rechts Platz, damit die Beschriftung des letzten Tages nicht abgeschnitten wird
cursor: { drag: { x: false, y: false } },
legend: { live: true },
scales: {
x: { time: false, range: () => [0, Math.max(daysRef.current.length - 1, 1)] },
y: { range: (u, min, max) => [0, Math.max(max, 1) * 1.08] },
},
axes: [
{
stroke: CHART.axis,
grid: { show: false },
ticks: { stroke: CHART.tick },
// etwa alle 80 px eine Beschriftung, vom letzten Tag rueckwaerts, damit der juengste Tag immer dabei ist
splits: (u) => {
const n = daysRef.current.length
const step = Math.max(1, Math.ceil(n / Math.max(2, Math.floor(u.width / 80))))
const out = []
for (let i = n - 1; i >= 0; i -= step) out.unshift(i)
return out
},
values: (u, splits) => splits.map((i) => formatDayShort(dayAt(i))),
},
{
stroke: CHART.axis,
grid: { stroke: CHART.grid },
ticks: { stroke: CHART.tick },
incrs: INCRS,
size: 50,
values: (u, vals) => vals.map((v) => formatInt(v)),
},
],
series: [
{ label: 'Tag', value: (u, v) => (v === null || v === undefined ? '–' : formatDay(dayAt(v))) },
...seriesRef.current.map((x) => ({
label: x.label,
stroke: x.color || CHART.line,
width: 2,
spanGaps: false,
points: { show: x.values.length < 2 },
fill: x.fill,
value: (u, v) => (v === null || v === undefined ? '–' : formatInt(v)),
})),
],
}
plot.current = new uPlot(opts, toData(daysRef.current, seriesRef.current), el)
const ro = new ResizeObserver(() => {
if (plot.current) plot.current.setSize({ width: Math.max(el.clientWidth, 200), height })
})
ro.observe(el)
return () => {
ro.disconnect()
plot.current?.destroy()
plot.current = null
}
// Neu aufbauen nur wenn sich Reihen oder Hoehe aendern; neue Werte kommen ueber setData
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [series.map((x) => x.label).join('|'), height])
useEffect(() => {
plot.current?.setData(toData(days, series))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, series])
return <div className="ws-chart" data-testid="ws-chart" ref={host} />
}