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:
ClaudeandClaude Opus 5.5 committed 2026-10-22 11:06:45 +00:00
commit 8363b63374
12 files changed
+790 -2

No files matched your search

+2
View File
@@ -21,6 +21,7 @@ import LegalPage from './pages/LegalPage'
import AssignProjectPage from './pages/AssignProjectPage'
import ServicesPage from './pages/ServicesPage'
import ServerpulsPage from './pages/ServerpulsPage'
import WebsiteStatistikPage from './pages/WebsiteStatistikPage'
import BankCallbackPage from './pages/BankCallbackPage'
import AdminRoute from './components/AdminRoute'
import RouteErrorBoundary from './components/RouteErrorBoundary'
@@ -75,6 +76,7 @@ function AppRoutes() {
<Route path="/finance/einstellungen" element={<ProtectedRoute><AdminRoute><FinanceSettingsPage /></AdminRoute></ProtectedRoute>} />
<Route path="/finanzen/bank-callback" element={<ProtectedRoute><AdminRoute><BankCallbackPage /></AdminRoute></ProtectedRoute>} />
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
<Route path="/statistik" element={<ProtectedRoute><WebsiteStatistikPage /></ProtectedRoute>} />
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
+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} />
}
+37
View File
@@ -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 }) })
}
+188
View File
@@ -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)}` : ''
}
+1
View File
@@ -22,6 +22,7 @@ export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
{ key: 'hustler', label: 'Hustler', href: '/hustler' },
...(isAdmin ? [{ key: 'finance', label: 'Finanzen', href: '/finance', also: '/finanzen' }] : []),
{ key: 'legal', label: 'Rechtliches', href: '/rechtliches' },
{ key: 'statistik', label: 'Website-Statistik', href: '/statistik' },
...(isAdmin ? [{ key: 'serverpuls', label: 'Serverpuls', href: '/serverpuls', dot: pulseLevel }] : []),
...(isAdmin ? [{ key: 'admin', label: 'Admin', href: '/admin' }] : []),
]
+165
View File
@@ -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>
)
}
+58
View File
@@ -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; }
}