diff --git a/src/App.jsx b/src/App.jsx index 72384f3..2e50fad 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/serverpuls/chartColors.js b/src/components/serverpuls/chartColors.js index a15f6c8..de30675 100644 --- a/src/components/serverpuls/chartColors.js +++ b/src/components/serverpuls/chartColors.js @@ -8,4 +8,5 @@ export const CHART = { axis: '#5e6678', grid: '#ebeef1', tick: '#d8dde3', + second: '#2563eb', // zweite Reihe (Website-Statistik: Besucher neben den Seitenaufrufen) } diff --git a/src/components/statistik/HeatmapBox.jsx b/src/components/statistik/HeatmapBox.jsx new file mode 100644 index 0000000..89acfb4 --- /dev/null +++ b/src/components/statistik/HeatmapBox.jsx @@ -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 ( +
+

Heatmap

+
+ + + +
+ {VIEWPORTS.map((vp) => ( + + ))} +
+
+ {error &&
{error}
} +

+ 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. +

+
+ ) +} diff --git a/src/components/statistik/RankList.jsx b/src/components/statistik/RankList.jsx new file mode 100644 index 0000000..9607594 --- /dev/null +++ b/src/components/statistik/RankList.jsx @@ -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 ( +
+

{title}

+ {hint &&
{hint}
} + {rows.length === 0 ? ( +
Keine Daten im Zeitraum.
+ ) : ( +
    + {shown.map((r) => ( +
  • +
    + {r.label} + + {formatInt(r.count)} + {r.percent !== null && {r.percent} %} + +
    + +
  • + ))} +
+ )} + {rows.length > LIMIT && ( + + )} +
+ ) +} diff --git a/src/components/statistik/TagesChart.jsx b/src/components/statistik/TagesChart.jsx new file mode 100644 index 0000000..0a5c195 --- /dev/null +++ b/src/components/statistik/TagesChart.jsx @@ -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
+} diff --git a/src/lib/eigeneAnalyseApi.js b/src/lib/eigeneAnalyseApi.js new file mode 100644 index 0000000..adfa160 --- /dev/null +++ b/src/lib/eigeneAnalyseApi.js @@ -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 }) }) +} diff --git a/src/lib/eigeneAnalyseView.js b/src/lib/eigeneAnalyseView.js new file mode 100644 index 0000000..adea41c --- /dev/null +++ b/src/lib/eigeneAnalyseView.js @@ -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)}` : '' +} diff --git a/src/lib/navLinks.js b/src/lib/navLinks.js index b7e30a6..6356f35 100644 --- a/src/lib/navLinks.js +++ b/src/lib/navLinks.js @@ -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' }] : []), ] diff --git a/src/pages/WebsiteStatistikPage.jsx b/src/pages/WebsiteStatistikPage.jsx new file mode 100644 index 0000000..398c994 --- /dev/null +++ b/src/pages/WebsiteStatistikPage.jsx @@ -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 ( +
+ {items.map((it) => ( + + ))} +
+ ) +} + +function Tile({ kpi, stats }) { + const change = kpiChange(stats, kpi.key) + const sub = change + ? {change.text} ggü. Vorperiode + : ' ' + return ( +
+ {kpi.label}{kpi.extra && {kpi.extra}}} + value={kpi.fmt(stats.totals[kpi.key])} + sub={sub} + /> +
+ ) +} + +function BannerBox({ banner }) { + const shown = banner?.shown || 0 + const rate = (n) => (shown ? formatPercent(n / shown) : '') + return ( +
+

Cookie-Banner

+
+ + + +
+
+ ) +} + +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 = ( +
+
{error}
+ +
+ ) + } else if (!stats) { + body =
Lädt …
+ } else if (hasNoData(stats)) { + body = ( +
+ +
+ ) + } else { + const since = sinceHint(stats) + const prev = stats.previous + body = ( + <> +

+ {periodText(stats.from, stats.to)} (ohne heute) + {prev && prev.complete && Vergleich mit {periodText(prev.from, prev.to)}} + {since && {since}} +

+ +
+ {KPIS.map((k) => )} +
+

{todayText(stats.today)}

+

Besucher zählt nur Besucher, die den Cookie-Banner angenommen haben. Seitenaufrufe zählen alle.

+ +
+

Verlauf pro Tag

+ +
+ +
+ + + + + + +
+ +
+ +
+ + ) + } + + return ( +
+ } + /> +
{body}
+
+ ) +} diff --git a/src/styles/website-statistik.css b/src/styles/website-statistik.css new file mode 100644 index 0000000..6239a45 --- /dev/null +++ b/src/styles/website-statistik.css @@ -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; } +} diff --git a/tests/unit/eigeneAnalyseView.test.mjs b/tests/unit/eigeneAnalyseView.test.mjs new file mode 100644 index 0000000..91c7722 --- /dev/null +++ b/tests/unit/eigeneAnalyseView.test.mjs @@ -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: '' }), '') +}) diff --git a/tests/unit/navLinks.test.mjs b/tests/unit/navLinks.test.mjs index 59c02ac..c34db1c 100644 --- a/tests/unit/navLinks.test.mjs +++ b/tests/unit/navLinks.test.mjs @@ -15,12 +15,13 @@ test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen, Admin-Eintraege n ['angebote', 'Abo-Angebote', '/customers/angebote', undefined], ['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.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) 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 === 'finance').also, '/finanzen') assert.equal(admin.find((l) => l.key === 'customers').children[0].count, 0, 'ohne Zahl 0')