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
@@ -21,6 +21,7 @@ import LegalPage from './pages/LegalPage'
|
|||||||
import AssignProjectPage from './pages/AssignProjectPage'
|
import AssignProjectPage from './pages/AssignProjectPage'
|
||||||
import ServicesPage from './pages/ServicesPage'
|
import ServicesPage from './pages/ServicesPage'
|
||||||
import ServerpulsPage from './pages/ServerpulsPage'
|
import ServerpulsPage from './pages/ServerpulsPage'
|
||||||
|
import WebsiteStatistikPage from './pages/WebsiteStatistikPage'
|
||||||
import BankCallbackPage from './pages/BankCallbackPage'
|
import BankCallbackPage from './pages/BankCallbackPage'
|
||||||
import AdminRoute from './components/AdminRoute'
|
import AdminRoute from './components/AdminRoute'
|
||||||
import RouteErrorBoundary from './components/RouteErrorBoundary'
|
import RouteErrorBoundary from './components/RouteErrorBoundary'
|
||||||
@@ -75,6 +76,7 @@ function AppRoutes() {
|
|||||||
<Route path="/finance/einstellungen" element={<ProtectedRoute><AdminRoute><FinanceSettingsPage /></AdminRoute></ProtectedRoute>} />
|
<Route path="/finance/einstellungen" element={<ProtectedRoute><AdminRoute><FinanceSettingsPage /></AdminRoute></ProtectedRoute>} />
|
||||||
<Route path="/finanzen/bank-callback" element={<ProtectedRoute><AdminRoute><BankCallbackPage /></AdminRoute></ProtectedRoute>} />
|
<Route path="/finanzen/bank-callback" element={<ProtectedRoute><AdminRoute><BankCallbackPage /></AdminRoute></ProtectedRoute>} />
|
||||||
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
|
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/statistik" element={<ProtectedRoute><WebsiteStatistikPage /></ProtectedRoute>} />
|
||||||
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
|
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
|
||||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const CHART = {
|
|||||||
axis: '#5e6678',
|
axis: '#5e6678',
|
||||||
grid: '#ebeef1',
|
grid: '#ebeef1',
|
||||||
tick: '#d8dde3',
|
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} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { account } from './appwrite'
|
||||||
|
|
||||||
|
const PROJECT_ADMIN_URL =
|
||||||
|
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||||
|
|
||||||
|
// Wie analyseFetch in analyseApi.js: der Fehler traegt .status und .data (Text aus dem Feld "error" der Antwort)
|
||||||
|
async function eigeneFetch(path, options = {}) {
|
||||||
|
const jwt = await account.createJWT()
|
||||||
|
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt.jwt}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(data.error || `API-Fehler ${response.status}`)
|
||||||
|
error.status = response.status
|
||||||
|
error.data = data
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = '/api/admin/analyse-eigen'
|
||||||
|
|
||||||
|
// Auswertung der eigenen Website (webklar.com); range = 7, 30 oder 90 Tage
|
||||||
|
export function fetchEigeneAnalyse(range) {
|
||||||
|
return eigeneFetch(`${base}?range=${encodeURIComponent(range)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einmal-Link zur Heatmap einer Seite (30 Min. gueltig); vp = m | t | d
|
||||||
|
export function eigeneHeatmapLink(path, vp) {
|
||||||
|
return eigeneFetch(`${base}/heatmap-link`, { method: 'POST', body: JSON.stringify({ path, vp }) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Reine Anzeige-Helfer fuer die Seite "Website-Statistik" (eigene Messung von webklar.com, kein React, unter Node testbar).
|
||||||
|
|
||||||
|
const intFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 })
|
||||||
|
|
||||||
|
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
||||||
|
|
||||||
|
// 1234 => "1.234"; kein Wert => "–"
|
||||||
|
export function formatInt(n) {
|
||||||
|
return isNum(n) ? intFormat.format(n) : '–'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anteil 0..1 => "63 %"
|
||||||
|
export function formatPercent(ratio) {
|
||||||
|
return isNum(ratio) ? `${intFormat.format(Math.round(ratio * 100))} %` : '–'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sekunden => "42 s" oder "1 Min. 05 s"; kein Wert => "–"
|
||||||
|
export function formatDuration(sec) {
|
||||||
|
if (!isNum(sec)) return '–'
|
||||||
|
const s = Math.max(0, Math.round(sec))
|
||||||
|
if (s < 60) return `${s} s`
|
||||||
|
return `${Math.floor(s / 60)} Min. ${String(s % 60).padStart(2, '0')} s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Vergleich mit der Vorperiode ---
|
||||||
|
|
||||||
|
const SIGN = { up: '+', down: '−', flat: '' }
|
||||||
|
|
||||||
|
function direction(diff) {
|
||||||
|
return diff > 0 ? 'up' : diff < 0 ? 'down' : 'flat'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aenderung in Prozent: "+12 %" / "−5 %" / "0 %" / "neu" (Vorperiode 0, jetzt mehr); fehlt ein Wert => null
|
||||||
|
export function changeVsPrevious(current, previous) {
|
||||||
|
if (!isNum(current) || !isNum(previous)) return null
|
||||||
|
if (previous === 0) return current > 0 ? { text: 'neu', dir: 'up' } : { text: '0 %', dir: 'flat' }
|
||||||
|
const pct = Math.round(((current - previous) / previous) * 100)
|
||||||
|
const dir = direction(pct)
|
||||||
|
return { text: `${SIGN[dir]}${intFormat.format(Math.abs(pct))} %`, dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quoten (0..1) vergleicht man in Prozentpunkten: "+4 Pkt."
|
||||||
|
export function changeInPoints(current, previous) {
|
||||||
|
if (!isNum(current) || !isNum(previous)) return null
|
||||||
|
const pts = Math.round((current - previous) * 100)
|
||||||
|
const dir = direction(pts)
|
||||||
|
return { text: `${SIGN[dir]}${intFormat.format(Math.abs(pts))} Pkt.`, dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aenderung einer Kennzahl aus stats.totals; ohne vollstaendige Vorperiode gibt es keinen Vergleich (null)
|
||||||
|
export function kpiChange(stats, key) {
|
||||||
|
const prev = stats?.previous
|
||||||
|
if (!prev || !prev.complete || !prev.totals || !stats.totals) return null
|
||||||
|
const fn = key === 'consentRate' ? changeInPoints : changeVsPrevious
|
||||||
|
return fn(stats.totals[key], prev.totals[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Beschriftungen ---
|
||||||
|
|
||||||
|
const DEVICES = { m: 'Handy', t: 'Tablet', d: 'Computer' }
|
||||||
|
const CONTACTS = { tel: 'Telefon', mail: 'E-Mail', wa: 'WhatsApp', out: 'Link zu anderer Website' }
|
||||||
|
|
||||||
|
let regionNames = null
|
||||||
|
try {
|
||||||
|
regionNames = new Intl.DisplayNames('de', { type: 'region' })
|
||||||
|
} catch {
|
||||||
|
regionNames = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceLabel(key) {
|
||||||
|
if (!key) return 'unbekannt'
|
||||||
|
return DEVICES[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
// "DE" => "Deutschland"; unbekannte oder ungueltige Codes bleiben als Code stehen
|
||||||
|
export function countryLabel(code) {
|
||||||
|
if (!code) return 'unbekannt'
|
||||||
|
try {
|
||||||
|
return (regionNames && regionNames.of(code)) || code
|
||||||
|
} catch {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contactLabel(kind) {
|
||||||
|
if (!kind) return 'unbekannt'
|
||||||
|
return CONTACTS[kind] || kind
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refLabel(key) {
|
||||||
|
return key ? key : 'direkt / unbekannt'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageLabel(path) {
|
||||||
|
return path === '/' ? '/ (Startseite)' : path
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Datum ---
|
||||||
|
|
||||||
|
// "2026-10-17" => "17.10.2026"; sonst "–"
|
||||||
|
export function formatDay(day) {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day || ''))
|
||||||
|
return m ? `${m[3]}.${m[2]}.${m[1]}` : '–'
|
||||||
|
}
|
||||||
|
|
||||||
|
// "17.10." fuer die Achse des Diagramms
|
||||||
|
export function formatDayShort(day) {
|
||||||
|
const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''))
|
||||||
|
return m ? `${m[2]}.${m[1]}.` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// "10.09.–16.10.2026"; ueber den Jahreswechsel mit beiden Jahren, bei einem Tag nur dieser
|
||||||
|
export function periodText(from, to) {
|
||||||
|
if (!from || !to) return ''
|
||||||
|
if (from === to) return formatDay(from)
|
||||||
|
const a = formatDay(from)
|
||||||
|
const b = formatDay(to)
|
||||||
|
return from.slice(0, 4) === to.slice(0, 4) ? `${a.slice(0, 6)}–${b}` : `${a}–${b}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(day, n) {
|
||||||
|
const d = new Date(`${day}T12:00:00Z`)
|
||||||
|
d.setUTCDate(d.getUTCDate() + n)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Verlauf pro Tag ---
|
||||||
|
|
||||||
|
// Jeder Tag von from bis to genau einmal. Fehlt ein Tag in der Antwort, zaehlt er als 0 (nichts passiert);
|
||||||
|
// Tage vor firstDay wurden noch nicht gemessen und bleiben null (Luecke im Diagramm).
|
||||||
|
export function chartDays(stats) {
|
||||||
|
const { from, to, firstDay, series } = stats
|
||||||
|
const byDay = new Map((series || []).map((s) => [s.day, s]))
|
||||||
|
const days = []
|
||||||
|
const pv = []
|
||||||
|
const visitors = []
|
||||||
|
if (!from || !to || from > to) return { days, pv, visitors }
|
||||||
|
for (let day = from; day <= to && days.length < 400; day = addDays(day, 1)) {
|
||||||
|
const row = byDay.get(day)
|
||||||
|
const unmeasured = !row && firstDay && day < firstDay
|
||||||
|
days.push(day)
|
||||||
|
pv.push(unmeasured ? null : row ? row.pv ?? 0 : 0)
|
||||||
|
visitors.push(unmeasured ? null : row ? row.visitors ?? 0 : 0)
|
||||||
|
}
|
||||||
|
return { days, pv, visitors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ranglisten ---
|
||||||
|
|
||||||
|
function sharePercent(count, sum) {
|
||||||
|
const pct = Math.round((count / sum) * 100)
|
||||||
|
return pct === 0 ? '<1' : String(pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eintraege {key,count} => Zeilen fuer die Liste: Label, Balken (0..1, bezogen auf den groessten Eintrag) und optional
|
||||||
|
// der Anteil an der Summe als Text ("58", unter einem halben Prozent "<1"; nur sinnvoll, wenn die Liste vollstaendig ist, z. B. Geraete)
|
||||||
|
export function rankRows(items, labelFn, { percent = false } = {}) {
|
||||||
|
const rows = (items || []).filter((x) => x && isNum(x.count) && x.count > 0).sort((a, b) => b.count - a.count)
|
||||||
|
const max = rows.length ? rows[0].count : 0
|
||||||
|
const sum = rows.reduce((acc, x) => acc + x.count, 0)
|
||||||
|
return rows.map((x) => ({
|
||||||
|
key: x.key,
|
||||||
|
label: labelFn(x.key),
|
||||||
|
count: x.count,
|
||||||
|
bar: max ? x.count / max : 0,
|
||||||
|
percent: percent && sum ? sharePercent(x.count, sum) : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Texte ---
|
||||||
|
|
||||||
|
const plural = (n, one, many) => `${formatInt(n)} ${n === 1 ? one : many}`
|
||||||
|
|
||||||
|
// "Heute bis jetzt: 12 Aufrufe · 3 Besucher · 1 Kontakt-Klick"
|
||||||
|
export function todayText(today) {
|
||||||
|
const t = today || {}
|
||||||
|
return `Heute bis jetzt: ${plural(t.pv || 0, 'Aufruf', 'Aufrufe')} · ${plural(t.visitors || 0, 'Besucher', 'Besucher')} · ${plural(t.contacts || 0, 'Kontakt-Klick', 'Kontakt-Klicks')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noch nichts gemessen: weder im Zeitraum noch heute
|
||||||
|
export function hasNoData(stats) {
|
||||||
|
return !stats || ((stats.totals?.pv || 0) === 0 && (stats.today?.pv || 0) === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Messung seit 05.10.2026", wenn die Messung erst nach Beginn des Zeitraums startete
|
||||||
|
export function sinceHint(stats) {
|
||||||
|
return stats && stats.firstDay && stats.from && stats.firstDay > stats.from ? `Messung seit ${formatDay(stats.firstDay)}` : ''
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
|
|||||||
{ key: 'hustler', label: 'Hustler', href: '/hustler' },
|
{ key: 'hustler', label: 'Hustler', href: '/hustler' },
|
||||||
...(isAdmin ? [{ key: 'finance', label: 'Finanzen', href: '/finance', also: '/finanzen' }] : []),
|
...(isAdmin ? [{ key: 'finance', label: 'Finanzen', href: '/finance', also: '/finanzen' }] : []),
|
||||||
{ key: 'legal', label: 'Rechtliches', href: '/rechtliches' },
|
{ key: 'legal', label: 'Rechtliches', href: '/rechtliches' },
|
||||||
|
{ key: 'statistik', label: 'Website-Statistik', href: '/statistik' },
|
||||||
...(isAdmin ? [{ key: 'serverpuls', label: 'Serverpuls', href: '/serverpuls', dot: pulseLevel }] : []),
|
...(isAdmin ? [{ key: 'serverpuls', label: 'Serverpuls', href: '/serverpuls', dot: pulseLevel }] : []),
|
||||||
...(isAdmin ? [{ key: 'admin', label: 'Admin', href: '/admin' }] : []),
|
...(isAdmin ? [{ key: 'admin', label: 'Admin', href: '/admin' }] : []),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { PageHeader, Stat, EmptyState } from '../components/ui'
|
||||||
|
import { fetchEigeneAnalyse } from '../lib/eigeneAnalyseApi'
|
||||||
|
import {
|
||||||
|
chartDays, contactLabel, countryLabel, deviceLabel, formatDuration, formatInt, formatPercent, hasNoData,
|
||||||
|
kpiChange, pageLabel, periodText, rankRows, refLabel, sinceHint, todayText,
|
||||||
|
} from '../lib/eigeneAnalyseView'
|
||||||
|
import TagesChart from '../components/statistik/TagesChart'
|
||||||
|
import RankList from '../components/statistik/RankList'
|
||||||
|
import HeatmapBox from '../components/statistik/HeatmapBox'
|
||||||
|
import { CHART } from '../components/serverpuls/chartColors'
|
||||||
|
import '../styles/website-statistik.css'
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ id: 7, label: '7 Tage' },
|
||||||
|
{ id: 30, label: '30 Tage' },
|
||||||
|
{ id: 90, label: '90 Tage' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// extra: Zusatz im Namen, der auf dem Handy entfaellt (steht dann in der Fussnote)
|
||||||
|
const KPIS = [
|
||||||
|
{ key: 'pv', label: 'Seitenaufrufe', fmt: formatInt },
|
||||||
|
{ key: 'visitors', label: 'Besucher', extra: ' (mit Zustimmung)', fmt: formatInt },
|
||||||
|
{ key: 'contacts', label: 'Kontakt-Klicks', fmt: formatInt },
|
||||||
|
{ key: 'consentRate', label: 'Zustimmungsquote', fmt: formatPercent },
|
||||||
|
{ key: 'avgSec', label: 'Ø Verweildauer', fmt: formatDuration },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Pills({ items, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="seg" data-testid="ws-range">
|
||||||
|
{items.map((it) => (
|
||||||
|
<button key={it.id} type="button" className={value === it.id ? 'on' : undefined} aria-pressed={value === it.id} onClick={() => onChange(it.id)}>
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ kpi, stats }) {
|
||||||
|
const change = kpiChange(stats, kpi.key)
|
||||||
|
const sub = change
|
||||||
|
? <span className={`ws-delta ws-${change.dir}`}>{change.text} ggü. Vorperiode</span>
|
||||||
|
: ' '
|
||||||
|
return (
|
||||||
|
<div className="ws-tile" data-testid={`ws-tile-${kpi.key}`}>
|
||||||
|
<Stat
|
||||||
|
label={<>{kpi.label}{kpi.extra && <span className="ws-k-extra">{kpi.extra}</span>}</>}
|
||||||
|
value={kpi.fmt(stats.totals[kpi.key])}
|
||||||
|
sub={sub}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BannerBox({ banner }) {
|
||||||
|
const shown = banner?.shown || 0
|
||||||
|
const rate = (n) => (shown ? formatPercent(n / shown) : '')
|
||||||
|
return (
|
||||||
|
<div className="ws-list ws-banner" data-testid="ws-banner">
|
||||||
|
<h3 className="ws-list-title">Cookie-Banner</h3>
|
||||||
|
<div className="stats ws-banner-stats">
|
||||||
|
<Stat label="Angezeigt" value={formatInt(shown)} />
|
||||||
|
<Stat label="Angenommen" value={formatInt(banner?.accept || 0)} sub={rate(banner?.accept || 0) || ' '} />
|
||||||
|
<Stat label="Abgelehnt" value={formatInt(banner?.reject || 0)} sub={rate(banner?.reject || 0) || ' '} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebsiteStatistikPage() {
|
||||||
|
const [range, setRange] = useState(30)
|
||||||
|
const [state, setState] = useState({ stats: null, loading: true, error: '' })
|
||||||
|
const [reload, setReload] = useState(0)
|
||||||
|
|
||||||
|
// Beim Wechsel des Zeitraums bleibt die alte Auswertung gedimmt stehen, bis die neue da ist
|
||||||
|
useEffect(() => {
|
||||||
|
let dead = false
|
||||||
|
setState((s) => ({ ...s, loading: true, error: '' }))
|
||||||
|
fetchEigeneAnalyse(range)
|
||||||
|
.then((r) => { if (!dead) setState({ stats: r.stats, loading: false, error: '' }) })
|
||||||
|
.catch((e) => { if (!dead) setState({ stats: null, loading: false, error: e.message || 'Die Auswertung konnte nicht geladen werden.' }) })
|
||||||
|
return () => { dead = true }
|
||||||
|
}, [range, reload])
|
||||||
|
|
||||||
|
const { stats, loading, error } = state
|
||||||
|
|
||||||
|
const chart = useMemo(() => {
|
||||||
|
if (!stats) return null
|
||||||
|
const c = chartDays(stats)
|
||||||
|
return {
|
||||||
|
days: c.days,
|
||||||
|
series: [
|
||||||
|
{ label: 'Seitenaufrufe', color: CHART.line, fill: CHART.fill, values: c.pv },
|
||||||
|
{ label: 'Besucher', color: CHART.second, values: c.visitors },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [stats])
|
||||||
|
|
||||||
|
let body = null
|
||||||
|
if (error) {
|
||||||
|
body = (
|
||||||
|
<div>
|
||||||
|
<div className="msg err" role="alert" data-testid="ws-error">{error}</div>
|
||||||
|
<button type="button" className="btn" onClick={() => setReload((n) => n + 1)}>Erneut laden</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (!stats) {
|
||||||
|
body = <div className="loading">Lädt …</div>
|
||||||
|
} else if (hasNoData(stats)) {
|
||||||
|
body = (
|
||||||
|
<div data-testid="ws-empty">
|
||||||
|
<EmptyState title="Noch keine Daten." hint=" Sobald webklar.com besucht wird, erscheinen hier die Zahlen." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const since = sinceHint(stats)
|
||||||
|
const prev = stats.previous
|
||||||
|
body = (
|
||||||
|
<>
|
||||||
|
<p className="ws-info muted small" data-testid="ws-period">
|
||||||
|
<span>{periodText(stats.from, stats.to)} (ohne heute)</span>
|
||||||
|
{prev && prev.complete && <span>Vergleich mit {periodText(prev.from, prev.to)}</span>}
|
||||||
|
{since && <span>{since}</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="stats ws-tiles" data-testid="ws-tiles">
|
||||||
|
{KPIS.map((k) => <Tile key={k.key} kpi={k} stats={stats} />)}
|
||||||
|
</section>
|
||||||
|
<p className="ws-today" data-testid="ws-today">{todayText(stats.today)}</p>
|
||||||
|
<p className="muted small ws-note">Besucher zählt nur Besucher, die den Cookie-Banner angenommen haben. Seitenaufrufe zählen alle.</p>
|
||||||
|
|
||||||
|
<section data-testid="ws-chart-panel">
|
||||||
|
<h2 className="section"><span>Verlauf pro Tag</span></h2>
|
||||||
|
<TagesChart days={chart.days} series={chart.series} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ws-lists" data-testid="ws-lists">
|
||||||
|
<RankList id="pages" title="Seiten" rows={rankRows(stats.pages, pageLabel)} />
|
||||||
|
<RankList id="refs" title="Herkunft" rows={rankRows(stats.refs, refLabel)} />
|
||||||
|
<RankList id="countries" title="Länder" rows={rankRows(stats.countries, countryLabel)} />
|
||||||
|
<RankList id="devices" title="Geräte" rows={rankRows(stats.devices, deviceLabel, { percent: true })} />
|
||||||
|
<RankList id="contacts" title="Kontakt-Klicks" rows={rankRows(stats.contacts, contactLabel, { percent: true })} />
|
||||||
|
<BannerBox banner={stats.banner} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<HeatmapBox pages={stats.pages || []} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page ws-page" data-testid="ws-page">
|
||||||
|
<PageHeader
|
||||||
|
title="Website-Statistik"
|
||||||
|
subtitle="webklar.com · eigene Messung, ohne Cookies"
|
||||||
|
actions={<Pills items={RANGES} value={range} onChange={setRange} />}
|
||||||
|
/>
|
||||||
|
<div className={loading && stats ? 'ws-body ws-busy' : 'ws-body'} aria-busy={loading}>{body}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/* Website-Statistik: Zeitraum, Kennzahlen, Verlauf, Ranglisten, Banner und Heatmap */
|
||||||
|
.ws-page .msg { margin: 0 0 12px; }
|
||||||
|
.ws-body { min-width: 0; transition: opacity 0.15s; }
|
||||||
|
.ws-body.ws-busy { opacity: 0.5; pointer-events: none; }
|
||||||
|
|
||||||
|
.ws-info { display: flex; flex-wrap: wrap; gap: 2px 16px; margin: 0 0 16px; }
|
||||||
|
|
||||||
|
.stats.ws-tiles { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 24px 32px; margin: 0 0 16px; }
|
||||||
|
.ws-tile { min-width: 0; }
|
||||||
|
.ws-delta { font-variant-numeric: tabular-nums; }
|
||||||
|
.ws-up { color: var(--green-fg); }
|
||||||
|
.ws-down { color: var(--red-fg); }
|
||||||
|
.ws-flat { color: var(--text-2); }
|
||||||
|
.ws-today { font-weight: 500; margin: 0 0 4px; }
|
||||||
|
.ws-note { margin: 0; }
|
||||||
|
|
||||||
|
.ws-page .ws-body > section:not(.ws-tiles) { margin-top: 36px; }
|
||||||
|
.ws-chart { width: 100%; min-height: 200px; }
|
||||||
|
.ws-chart .u-legend { font-size: 13px; color: var(--text-2); }
|
||||||
|
|
||||||
|
.ws-lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: 32px 48px; }
|
||||||
|
.ws-list { min-width: 0; }
|
||||||
|
.ws-list-title { font-size: 16px; line-height: 24px; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.ws-rows { list-style: none; }
|
||||||
|
.ws-row { padding: 8px 0 10px; }
|
||||||
|
.ws-row-top { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||||
|
.ws-row-label { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.ws-row-count { white-space: nowrap; font-weight: 500; }
|
||||||
|
.ws-row-pct { margin-left: 8px; font-weight: 400; color: var(--text-2); }
|
||||||
|
.ws-bar { height: 4px; border-radius: 2px; background: var(--bg-hover); margin-top: 6px; overflow: hidden; }
|
||||||
|
.ws-bar > span { display: block; height: 100%; border-radius: 2px; background: var(--accent); }
|
||||||
|
.ws-list .ws-more { display: inline-block; margin-top: 8px; font-size: 13px; } /* gleiche Staerke wie button.link, sonst gewinnt dessen font: inherit */
|
||||||
|
|
||||||
|
.stats.ws-banner-stats { gap: 24px 40px; margin: 12px 0 0; }
|
||||||
|
.ws-hm-controls { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px; margin-bottom: 12px; }
|
||||||
|
.page .ws-hm-controls .field { flex: 1 1 260px; max-width: 420px; margin-bottom: 0; } /* KOMPAT: .page .field gibt sonst 12 px unten, die Knoepfe stuenden zu hoch */
|
||||||
|
.ws-hm-controls .select { width: 100%; }
|
||||||
|
.ws-hm-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.ws-hm-error { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
@media (max-width: 1240px) {
|
||||||
|
.stats.ws-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.stats.ws-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fuenf Spalten auf einem schmalen Desktop und zwei auf dem Handy: dort ist fuer "(mit Zustimmung)" kein Platz (steht in der Fussnote) */
|
||||||
|
@media (min-width: 1241px) and (max-width: 1399px), (max-width: 520px) {
|
||||||
|
.ws-k-extra { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.ws-page .page-head .actions { width: 100%; }
|
||||||
|
.ws-page .seg { width: 100%; }
|
||||||
|
.ws-page .seg button { flex: 1 1 0; white-space: nowrap; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
// Website-Statistik (webklar.com): reine Anzeige-Helfer
|
||||||
|
const v = await loadModule('src/lib/eigeneAnalyseView.js')
|
||||||
|
|
||||||
|
test('Zahlen: Tausenderpunkt, Prozent, fehlender Wert', () => {
|
||||||
|
assert.equal(v.formatInt(1234567), '1.234.567')
|
||||||
|
assert.equal(v.formatInt(0), '0')
|
||||||
|
assert.equal(v.formatInt(null), '–')
|
||||||
|
assert.equal(v.formatInt(undefined), '–')
|
||||||
|
assert.equal(v.formatPercent(0.63), '63 %')
|
||||||
|
assert.equal(v.formatPercent(0.625), '63 %')
|
||||||
|
assert.equal(v.formatPercent(0), '0 %')
|
||||||
|
assert.equal(v.formatPercent(1), '100 %')
|
||||||
|
assert.equal(v.formatPercent(null), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Dauer: Sekunden, Minuten mit zweistelligen Sekunden, null', () => {
|
||||||
|
assert.equal(v.formatDuration(42), '42 s')
|
||||||
|
assert.equal(v.formatDuration(0), '0 s')
|
||||||
|
assert.equal(v.formatDuration(65), '1 Min. 05 s')
|
||||||
|
assert.equal(v.formatDuration(60), '1 Min. 00 s')
|
||||||
|
assert.equal(v.formatDuration(59.6), '1 Min. 00 s', 'gerundet, nie "60 s"')
|
||||||
|
assert.equal(v.formatDuration(754.2), '12 Min. 34 s')
|
||||||
|
assert.equal(v.formatDuration(null), '–')
|
||||||
|
assert.equal(v.formatDuration(undefined), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Aenderung in Prozent: plus, minus (U+2212), null, neu, kein Vergleich', () => {
|
||||||
|
assert.deepEqual(v.changeVsPrevious(112, 100), { text: '+12 %', dir: 'up' })
|
||||||
|
assert.deepEqual(v.changeVsPrevious(95, 100), { text: '−5 %', dir: 'down' })
|
||||||
|
assert.deepEqual(v.changeVsPrevious(100, 100), { text: '0 %', dir: 'flat' })
|
||||||
|
assert.deepEqual(v.changeVsPrevious(1001, 1000), { text: '0 %', dir: 'flat' }, 'unter 0,5 % gilt als unveraendert')
|
||||||
|
assert.deepEqual(v.changeVsPrevious(5, 0), { text: 'neu', dir: 'up' })
|
||||||
|
assert.deepEqual(v.changeVsPrevious(0, 0), { text: '0 %', dir: 'flat' })
|
||||||
|
assert.deepEqual(v.changeVsPrevious(0, 40), { text: '−100 %', dir: 'down' })
|
||||||
|
assert.deepEqual(v.changeVsPrevious(13000, 1000), { text: '+1.200 %', dir: 'up' })
|
||||||
|
assert.equal(v.changeVsPrevious(null, 10), null)
|
||||||
|
assert.equal(v.changeVsPrevious(10, null), null)
|
||||||
|
assert.equal(v.changeVsPrevious(10, undefined), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Aenderung der Zustimmungsquote in Prozentpunkten', () => {
|
||||||
|
assert.deepEqual(v.changeInPoints(0.63, 0.59), { text: '+4 Pkt.', dir: 'up' })
|
||||||
|
assert.deepEqual(v.changeInPoints(0.5, 0.52), { text: '−2 Pkt.', dir: 'down' })
|
||||||
|
assert.deepEqual(v.changeInPoints(0.5, 0.5), { text: '0 Pkt.', dir: 'flat' })
|
||||||
|
assert.equal(v.changeInPoints(0.5, null), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kpiChange: ohne vollstaendige Vorperiode kein Vergleich', () => {
|
||||||
|
const stats = {
|
||||||
|
totals: { pv: 120, visitors: 40, contacts: 3, consentRate: 0.63, avgSec: 65 },
|
||||||
|
previous: { complete: true, totals: { pv: 100, visitors: 50, contacts: 0, consentRate: 0.59, avgSec: null } },
|
||||||
|
}
|
||||||
|
assert.equal(v.kpiChange(stats, 'pv').text, '+20 %')
|
||||||
|
assert.equal(v.kpiChange(stats, 'visitors').text, '−20 %')
|
||||||
|
assert.equal(v.kpiChange(stats, 'contacts').text, 'neu')
|
||||||
|
assert.equal(v.kpiChange(stats, 'consentRate').text, '+4 Pkt.')
|
||||||
|
assert.equal(v.kpiChange(stats, 'avgSec'), null, 'Vorperiode ohne Dauer')
|
||||||
|
assert.equal(v.kpiChange({ ...stats, previous: { ...stats.previous, complete: false } }, 'pv'), null)
|
||||||
|
assert.equal(v.kpiChange({ totals: stats.totals }, 'pv'), null)
|
||||||
|
assert.equal(v.kpiChange(null, 'pv'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Beschriftungen: Geraete, Kontakte, Herkunft, Seiten', () => {
|
||||||
|
assert.equal(v.deviceLabel('m'), 'Handy')
|
||||||
|
assert.equal(v.deviceLabel('t'), 'Tablet')
|
||||||
|
assert.equal(v.deviceLabel('d'), 'Computer')
|
||||||
|
assert.equal(v.deviceLabel(''), 'unbekannt')
|
||||||
|
assert.equal(v.deviceLabel('x'), 'x')
|
||||||
|
assert.equal(v.contactLabel('tel'), 'Telefon')
|
||||||
|
assert.equal(v.contactLabel('mail'), 'E-Mail')
|
||||||
|
assert.equal(v.contactLabel('wa'), 'WhatsApp')
|
||||||
|
assert.equal(v.contactLabel('out'), 'Link zu anderer Website')
|
||||||
|
assert.equal(v.contactLabel('zzz'), 'zzz')
|
||||||
|
assert.equal(v.refLabel(''), 'direkt / unbekannt')
|
||||||
|
assert.equal(v.refLabel('google.com'), 'google.com')
|
||||||
|
assert.equal(v.pageLabel('/'), '/ (Startseite)')
|
||||||
|
assert.equal(v.pageLabel('/preise'), '/preise')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Laender: deutsche Namen, unbekannt, ungueltiger Code bleibt Code', () => {
|
||||||
|
assert.equal(v.countryLabel('DE'), 'Deutschland')
|
||||||
|
assert.equal(v.countryLabel('AT'), 'Österreich')
|
||||||
|
assert.equal(v.countryLabel('CH'), 'Schweiz')
|
||||||
|
assert.equal(v.countryLabel(''), 'unbekannt')
|
||||||
|
assert.equal(v.countryLabel('T1'), 'T1', 'kein gueltiger Regionscode')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Datum: Tag, Kurzform, Zeitraum (gleiches Jahr, Jahreswechsel, ein Tag)', () => {
|
||||||
|
assert.equal(v.formatDay('2026-10-17'), '17.10.2026')
|
||||||
|
assert.equal(v.formatDay(''), '–')
|
||||||
|
assert.equal(v.formatDay('17.10.2026'), '–')
|
||||||
|
assert.equal(v.formatDayShort('2026-10-07'), '07.10.')
|
||||||
|
assert.equal(v.periodText('2026-09-10', '2026-10-16'), '10.09.–16.10.2026')
|
||||||
|
assert.equal(v.periodText('2025-12-30', '2026-01-05'), '30.12.2025–05.01.2026')
|
||||||
|
assert.equal(v.periodText('2026-10-16', '2026-10-16'), '16.10.2026')
|
||||||
|
assert.equal(v.periodText('', '2026-10-16'), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Verlauf: jeder Tag einmal, fehlende Tage 0, Tage vor der Messung null, ueber Monatsgrenze', () => {
|
||||||
|
const stats = {
|
||||||
|
from: '2026-09-29', to: '2026-10-03', firstDay: '2026-09-30',
|
||||||
|
series: [
|
||||||
|
{ day: '2026-09-30', pv: 4, visitors: 2 },
|
||||||
|
{ day: '2026-10-02', pv: 9, visitors: 5 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const c = v.chartDays(stats)
|
||||||
|
assert.deepEqual(c.days, ['2026-09-29', '2026-09-30', '2026-10-01', '2026-10-02', '2026-10-03'])
|
||||||
|
assert.deepEqual(c.pv, [null, 4, 0, 9, 0])
|
||||||
|
assert.deepEqual(c.visitors, [null, 2, 0, 5, 0])
|
||||||
|
assert.deepEqual(v.chartDays({ from: '2026-10-03', to: '2026-10-01', series: [] }).days, [])
|
||||||
|
assert.deepEqual(v.chartDays({ from: '2026-10-01', to: '2026-10-02', firstDay: '', series: [] }).pv, [0, 0])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Rangliste: sortiert, Balken relativ zum groessten, Anteil nur auf Wunsch, Nullen raus', () => {
|
||||||
|
const items = [{ key: 'm', count: 30 }, { key: 'd', count: 60 }, { key: 't', count: 10 }, { key: '', count: 0 }]
|
||||||
|
const rows = v.rankRows(items, v.deviceLabel, { percent: true })
|
||||||
|
assert.deepEqual(rows.map((r) => [r.key, r.label, r.count, r.bar, r.percent]), [
|
||||||
|
['d', 'Computer', 60, 1, '60'],
|
||||||
|
['m', 'Handy', 30, 0.5, '30'],
|
||||||
|
['t', 'Tablet', 10, 10 / 60, '10'],
|
||||||
|
])
|
||||||
|
assert.equal(v.rankRows([{ key: 'a', count: 1000 }, { key: 'b', count: 1 }], String, { percent: true })[1].percent, '<1', 'unter einem halben Prozent')
|
||||||
|
assert.equal(v.rankRows(items, v.deviceLabel)[0].percent, null)
|
||||||
|
assert.deepEqual(v.rankRows(null, String), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Texte: Heute-Zeile mit Einzahl, Leerzustand, Hinweis auf spaeten Messbeginn', () => {
|
||||||
|
assert.equal(
|
||||||
|
v.todayText({ pv: 12, visitors: 3, contacts: 1 }),
|
||||||
|
'Heute bis jetzt: 12 Aufrufe · 3 Besucher · 1 Kontakt-Klick'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
v.todayText({ pv: 1, visitors: 1, contacts: 0 }),
|
||||||
|
'Heute bis jetzt: 1 Aufruf · 1 Besucher · 0 Kontakt-Klicks'
|
||||||
|
)
|
||||||
|
assert.equal(v.todayText({ pv: 1500, visitors: 0, contacts: 2 }), 'Heute bis jetzt: 1.500 Aufrufe · 0 Besucher · 2 Kontakt-Klicks')
|
||||||
|
assert.equal(v.hasNoData({ totals: { pv: 0 }, today: { pv: 0 } }), true)
|
||||||
|
assert.equal(v.hasNoData({ totals: { pv: 0 }, today: { pv: 2 } }), false)
|
||||||
|
assert.equal(v.hasNoData({ totals: { pv: 5 }, today: { pv: 0 } }), false)
|
||||||
|
assert.equal(v.sinceHint({ from: '2026-09-17', firstDay: '2026-10-05' }), 'Messung seit 05.10.2026')
|
||||||
|
assert.equal(v.sinceHint({ from: '2026-09-17', firstDay: '2026-09-17' }), '')
|
||||||
|
assert.equal(v.sinceHint({ from: '2026-09-17', firstDay: '' }), '')
|
||||||
|
})
|
||||||
@@ -15,12 +15,13 @@ test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen, Admin-Eintraege n
|
|||||||
['angebote', 'Abo-Angebote', '/customers/angebote', undefined],
|
['angebote', 'Abo-Angebote', '/customers/angebote', undefined],
|
||||||
['speicher', 'E-Mail-Speicher', '/customers/email-speicher', 2],
|
['speicher', 'E-Mail-Speicher', '/customers/email-speicher', 2],
|
||||||
])
|
])
|
||||||
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'hustler', 'legal'])
|
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'hustler', 'legal', 'statistik'])
|
||||||
assert.deepEqual(links.find((l) => l.key === 'hustler'), { key: 'hustler', label: 'Hustler', href: '/hustler' })
|
assert.deepEqual(links.find((l) => l.key === 'hustler'), { key: 'hustler', label: 'Hustler', href: '/hustler' })
|
||||||
assert.ok(!links.some((l) => l.key === 'leads' || l.key === 'workers'))
|
assert.ok(!links.some((l) => l.key === 'leads' || l.key === 'workers'))
|
||||||
|
assert.deepEqual(links.find((l) => l.key === 'statistik'), { key: 'statistik', label: 'Website-Statistik', href: '/statistik' }, 'fuer alle Mitarbeiter sichtbar')
|
||||||
assert.equal(kunden.children.find((c) => c.key === 'chat').hot, true)
|
assert.equal(kunden.children.find((c) => c.key === 'chat').hot, true)
|
||||||
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', counts: {} })
|
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', counts: {} })
|
||||||
assert.deepEqual(admin.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'hustler', 'finance', 'legal', 'serverpuls', 'admin'])
|
assert.deepEqual(admin.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'hustler', 'finance', 'legal', 'statistik', 'serverpuls', 'admin'])
|
||||||
assert.equal(admin.find((l) => l.key === 'serverpuls').dot, 'warn')
|
assert.equal(admin.find((l) => l.key === 'serverpuls').dot, 'warn')
|
||||||
assert.equal(admin.find((l) => l.key === 'finance').also, '/finanzen')
|
assert.equal(admin.find((l) => l.key === 'finance').also, '/finanzen')
|
||||||
assert.equal(admin.find((l) => l.key === 'customers').children[0].count, 0, 'ohne Zahl 0')
|
assert.equal(admin.find((l) => l.key === 'customers').children[0].count, 0, 'ohne Zahl 0')
|
||||||
|
|||||||
Reference in new issue
Block a user