Merge remote-tracking branch 'origin/test' into feature/hustler-live
Hustler-Pixelwelt mit Stufen-Sperre (Stufe 3 = Dienst P3) mit Website-Statistik (f9b0cbc) zusammengeführt; Konflikt in tests/unit/navLinks.test.mjs: beide Listen vereint (hustler statt leads, statistik vor serverpuls).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8363b63374
12 files changed
+790
-2
No files matched your search
@@ -8,4 +8,5 @@ export const CHART = {
|
||||
axis: '#5e6678',
|
||||
grid: '#ebeef1',
|
||||
tick: '#d8dde3',
|
||||
second: '#2563eb', // zweite Reihe (Website-Statistik: Besucher neben den Seitenaufrufen)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
Reference in new issue
Block a user