From 55e98b038c4ad6a2807f069c2e214b50b5ec2c38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:18:15 +0000 Subject: [PATCH] =?UTF-8?q?Analyse:=20HTML-Bausteine=20der=20Cockpit-?= =?UTF-8?q?=C3=9Cbersicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reine String-Builder (Kopfleiste, Reiter, Das Wichtigste, Kacheln mit Mini-Verlauf und Chips, Diagramm, Kontakt-Aktionen, Ranglisten, Datenbasis, Heatmap-Karte, Laden, Fehler, Einstellungen) für die neue Analyse-Ansicht. Co-Authored-By: Claude Opus 5.5 --- public/analyse-cards.js | 273 +++++++++++++++++++++++++++++++ test/analyseCards.test.js | 334 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 607 insertions(+) create mode 100644 public/analyse-cards.js create mode 100644 test/analyseCards.test.js diff --git a/public/analyse-cards.js b/public/analyse-cards.js new file mode 100644 index 0000000..054d473 --- /dev/null +++ b/public/analyse-cards.js @@ -0,0 +1,273 @@ +/* HTML-Bausteine der Analyse-Uebersicht ("Cockpit"). Reine String-Builder ohne DOM und ohne Netz; analyse.js setzt sie ein, analyse.css stylt sie. + Jeder Wert aus Daten (Pfade, Hosts, Projektnamen, Tage, Links) laeuft durch esc(). Texte: Spec 2.1-2.4. + + Vertrag fuer analyse.js und analyse.css (Praefix an-, Zustaende als Klasse "on" oder Attribut): + Kopf .an-top > .an-site[data-state=on|warn|off] (.an-status-dot, .an-site-name | select#an-project.an-pick, .an-status) + .an-seg[role=group] button[data-range=7|30|90][aria-pressed] (.on) + Reiter .an-tabs[role=tablist] > button.an-tabbtn[role=tab][data-tab=uebersicht|heatmap|einstellungen][aria-selected] (.on), aria-controls="an-stage" + Wurzel .an-ov (Raster, Abstand 14 px) enthaelt: .an-sum, .an-kpis, .an-row2, 2x .an-pair, .an-credit + Wichtigste .an-sum > h3.an-eyebrow, p.an-lead (darin ), ul.an-points > li > span.an-point-ico (svg.an-ico) + span; Leerzustand: .an-sum.an-sum-empty + Kacheln .an-kpis > 4x .an-card.an-kpi[data-kpi=pv|visitors|contacts|time] > span.an-k (span.an-star), b.an-v, small.an-sub, .an-cmp (span.an-delta.up|down|flat[role=img][aria-label], span.an-vs), svg.an-spark (path.an-spark-area, path.an-spark-line) + Diagramm .an-card.an-chartbox > .an-head (h3, .an-legend > span.an-leg > i.an-leg-line[.an-leg-prev]) + .an-chartwrap > svg.an-chart (line.an-grid, text.an-axis, path.an-area, path.an-prev nur mit Vergleich, path.an-line, + circle.an-dot[data-i][data-day][data-pv][data-prev] fuer jeden Tag, per CSS erst bei Fokus sichtbar) + div.an-tip[hidden] (Blase; Text aus chartTipText) + Kontakte .an-card.an-contacts > .an-head (h3, b.an-sum-n) + ul.an-crows > li.an-crow[data-kind=tel|mail|wa|out] (span.an-cico, span.an-cname, b, span.an-delta) + p.an-quote; Leertext: p.an-empty + Ranglisten section.an-card.an-rank[data-rank=seen|who][data-rank-active=] > h3, .an-ltabs[role=group] > button.an-ltab[data-rank-tab][aria-pressed] (.on), ul.an-lrows > li.an-lr + (span.an-lab > i.an-fill[style=width:N%] + span.an-name[title], b, em), button.an-more[data-rank-more] ("Alle anzeigen", nur bei mehr als 6 Zeilen und nicht aufgeklappt) + Datenbasis .an-card.an-basis > svg.an-ring (circle.an-ring-bg, circle.an-ring-fg, text.an-ring-n) + p.an-basis-text > span.an-basis-main + Heatmap .an-card.an-teaser > .an-teaser-row > .an-mini-heat + p + button.an-btn[data-tab=heatmap] (data-tab auch auf dem Reiterknopf: Handler auf .an-tabs bzw. .an-teaser einschraenken) + Einstell. .an-ov.an-settings > .an-card.an-status-card (dl.an-dl, span.an-swatch) + .an-card.an-docs (ul > li > a) + section.an-card#an-off (leer, fuellt renderAnalyseOff) + Laden .an-ov.an-skeleton[aria-busy] mit .an-sk-Platzhaltern (.an-sk > i); .an-sr = nur fuer Screenreader sichtbar (muss in analyse.css stehen) + Fehler section.an-card.an-error[role=alert] > p + button.an-btn#an-retry */ + +import { esc } from './fn-util.js' +import { fmtNum, fmtDuration, periodLabel, shortDay, dayLabel, compareFrom, delta, contactRate, refName, summarize, chartModel, barRows, countryName, flagEmoji, DEVICE_LABELS } from './analyse-view.js' + +const RANGES = [7, 30, 90] +const HEX_RE = /^#[0-9a-f]{6}$/i +const isNum = (v) => typeof v === 'number' && Number.isFinite(v) +const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0) +const round = (n) => Math.round(n * 10) / 10 +const list = (v) => (Array.isArray(v) ? v : []) + +// Symbole wie ICONS in funktionen.js: 24er Raster, nur Striche +const PHONE = '' +const ICONS = { + contact: PHONE, + tel: PHONE, + mail: '', + wa: '', + out: '', + day: '', + source: '', + device: '', + page: '', +} +const ico = (name) => `` + +// Veraenderungs-Chip aus delta(); ohne Wert nichts +const chip = (d) => (d ? `${esc(d.text)}` : '') +const rateDe = (n) => n.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + +/* --- Kopfleiste und Reiter, Spec 2.1 --- */ + +const STATE_TEXT = { disabled: 'Analyse ist abgeschaltet', unknown: 'Status gerade nicht abrufbar' } + +// Statuszeile: aktiv (mit Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand +function statusOf(project, devMode, stamp) { + if (devMode) return { s: 'warn', text: 'Im Dev-Modus wird nicht gemessen.' } + const a = project?.analyse + const state = typeof a?.state === 'string' ? a.state : 'active' + if (state === 'active') return { s: 'on', text: `Analyse aktiv${stamp ? ` · Stand: heute, ${stamp} Uhr` : ''}` } + return { s: 'off', text: Object.hasOwn(STATE_TEXT, state) ? STATE_TEXT[state] : a.eligible === false ? 'Analyse nicht verfügbar' : 'Analyse ist noch ausgeschaltet' } +} + +/** Kopfleiste. usable: Websites mit Analyse (Auswahlfeld erst ab 2), stamp: Uhrzeit "19:42" des Ladens. */ +export function headerHtml({ project, usable, range, showRange, devMode, stamp } = {}) { + const sites = list(usable) + const st = statusOf(project, devMode, stamp ? esc(stamp) : '') + const current = RANGES.includes(Number(range)) ? Number(range) : 30 + const name = sites.length > 1 + ? `` + : `${esc(project?.projectName)}` + const seg = showRange + ? `
${RANGES.map((r) => ``).join('')}
` + : '' + return `
${name}${st.text}
${seg}
` +} + +const TABS = [['uebersicht', 'Übersicht'], ['heatmap', 'Heatmap'], ['einstellungen', 'Einstellungen']] + +export function tabsHtml(tab) { + const active = TABS.some(([k]) => k === tab) ? tab : 'uebersicht' + return `
${TABS.map(([k, label]) => ``).join('')}
` +} + +/* --- Das Wichtigste und Kennzahlen, Spec 2.1, 2.3, 2.4 --- */ + +export function summaryHtml(stats) { + const s = summarize(stats) + const period = periodLabel(stats?.from, stats?.to) + const head = `

Das Wichtigste${period ? ` · ${esc(period)}` : ''}

` + if (!s) return `
${head}

Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.

` + // lead und items[].html liefert summarize bereits maskiert + const points = s.items.length ? `
    ${s.items.map((i) => `
  • ${ico(i.icon)}${i.html}
  • `).join('')}
` : '' + return `
${head}

${s.lead}

${points}
` +} + +/** Mini-Verlauf (Flaeche + Linie, ohne Achsen) aus Zahlen; unter 2 Werten nichts. */ +export function sparkHtml(values) { + const v = list(values).map(num) + if (v.length < 2) return '' + const max = Math.max(1, ...v) * 1.1 + const path = v.map((y, i) => `${i ? 'L' : 'M'}${round((200 * i) / (v.length - 1))} ${round(36 - (32 * y) / max)}`).join(' ') + return `` +} + +// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null +const trend = (series, key) => { + const rows = list(series) + return rows.some((d) => Object.hasOwn(d ?? {}, key)) ? rows.map((d) => num(d?.[key])) : null +} + +export function kpisHtml(stats) { + const t = stats?.totals || {} + const cmp = compareFrom(stats) + const p = cmp.ok ? stats.previous.totals || {} : null + const tiles = [ + { key: 'pv', label: 'Seitenaufrufe', value: fmtNum(t.pv), d: p && delta(t.pv, p.pv), vs: p && fmtNum(p.pv) }, + { key: 'visitors', label: 'Besucher', star: true, value: fmtNum(t.visitors), d: p && delta(t.visitors, p.visitors), vs: p && fmtNum(p.visitors) }, + { key: 'contacts', label: 'Kontakt-Aktionen', star: true, value: fmtNum(t.contacts), d: p && delta(t.contacts, p.contacts), vs: p && fmtNum(p.contacts) }, + { key: 'time', label: 'Ø Zeit pro Seite', star: true, value: fmtDuration(t.avgSec), sub: isNum(t.avgScroll) ? `Ø ${fmtNum(t.avgScroll)} % gescrollt` : '', d: p && delta(t.avgSec, p.avgSec, { kind: 'sec' }), vs: p && fmtDuration(p.avgSec) }, + ] + const field = { pv: 'pv', visitors: 'visitors', contacts: 'contacts', time: 'avgSec' } + const tile = (k) => { + const foot = k.d ? `${chip(k.d)}vs. ${esc(k.vs)}` : !cmp.ok && cmp.from ? `Vergleich ab ${esc(cmp.from)}` : '' + return `
${k.label}${k.star ? '*' : ''}${esc(k.value)}${k.sub ? `${esc(k.sub)}` : ''}
${foot}
${sparkHtml(trend(stats?.series, field[k.key]))}
` + } + return `
${tiles.map(tile).join('')}
` +} + +/* --- Diagramm und Kontakt-Aktionen, Spec 2.1 --- */ + +/** Text der Blase ueber einem Tag: "Sa., 19.09.: 371 Aufrufe (davor 210)"; prev leer/null => ohne Klammer. */ +export function chartTipText(day, pv, prev) { + const n = num(pv) + const before = prev === null || prev === undefined || prev === '' ? '' : ` (davor ${fmtNum(prev)})` + return `${dayLabel(day)}: ${fmtNum(n)} ${n === 1 ? 'Aufruf' : 'Aufrufe'}${before}` +} + +export function chartHtml(stats) { + const series = list(stats?.series) + const cmp = compareFrom(stats) + const head = (legend) => `

Seitenaufrufe pro Tag

${legend}
` + if (!series.length) return `
${head('')}

Noch keine Daten.

` + const m = chartModel(series, { height: 300, pad: { l: 40, r: 24, t: 12, b: 28 }, compare: cmp.ok ? stats.previous.series : undefined }) + const hasPrev = Boolean(m.prevLine) + const days = Number(stats?.range) || series.length + const total = m.points.reduce((sum, p) => sum + p.pv, 0) + const title = `Seitenaufrufe pro Tag vom ${shortDay(m.points[0].day)} bis ${shortDay(m.points[m.points.length - 1].day)}, insgesamt ${fmtNum(total)}` + const legend = `
${days} Tage${hasPrev ? 'Zeitraum davor' : ''}
` + const dots = m.points.map((p, i) => ``).join('') + return `
${head(legend)}
${esc(title)}` + + m.yTicks.map((k) => `${esc(k.label)}`).join('') + + m.xLabels.map((l) => `${esc(l.label)}`).join('') + + `${hasPrev ? `` : ''}${dots}
` +} + +const CONTACT_KINDS = [['tel', 'Anrufe'], ['mail', 'E-Mails'], ['wa', 'WhatsApp'], ['out', 'Links nach außen']] + +export function contactsHtml(stats) { + const t = stats?.totals || {} + const total = num(t.contacts) + const cmp = compareFrom(stats) + const before = cmp.ok ? stats.previous : null + const head = `

Kontakt-Aktionen

${fmtNum(total)}
` + if (total < 1) return `
${head}

Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.

` + const countOf = (rows, key) => num(list(rows).find((c) => c?.key === key)?.count) + const rows = CONTACT_KINDS.map(([key, label]) => { + const cur = countOf(stats.contacts, key) + const was = before ? countOf(before.contacts, key) : 0 + if (!cur && !was) return '' + return `
  • ${ico(key)}${label}${fmtNum(cur)}${chip(before && delta(cur, was))}
  • ` + }).join('') + // Kontakt-Aktionen sind Klicks, keine Personen: "pro 100 Besucher"; ab 30 Besuchern + const rate = contactRate(total, num(t.visitors)) + const wasRate = before ? contactRate(num(before.totals?.contacts), num(before.totals?.visitors)) : null + const quote = rate === null ? '' : `

    Auf 100 Besucher kommen ${rateDe(rate)} Kontakt-Aktionen${wasRate === null ? '' : ` (davor ${rateDe(wasRate)})`}.

    ` + return `
    ${head}${rows ? `
      ${rows}
    ` : ''}${quote}
    ` +} + +/* --- Ranglisten, Spec 2.1 --- */ + +const CARDS = { + seen: { title: 'Was angesehen wird', tabs: [['pages', 'Seiten'], ['refs', 'Herkunft']] }, + who: { title: 'Wer zu Besuch ist', tabs: [['devices', 'Geräte'], ['countries', 'Länder']] }, +} +const NAMERS = { + pages: (key) => (key === '' || key === '/' ? 'Startseite' : key), + refs: refName, + devices: (key) => (Object.hasOwn(DEVICE_LABELS, key) ? DEVICE_LABELS[key] : 'Unbekannt'), + countries: countryName, +} +const SHORT = 6 +const LONG = 20 + +/** Rangliste "seen" (Seiten | Herkunft) oder "who" (Geraete | Laender); tab waehlt den Reiter, expanded zeigt bis 20 statt 6 Zeilen. */ +export function rankCardHtml(card, stats, { tab, expanded } = {}) { + if (!Object.hasOwn(CARDS, card)) return '' + const def = CARDS[card] + const active = def.tabs.some(([k]) => k === tab) ? tab : def.tabs[0][0] + const items = list(stats?.[active]) + const sum = items.reduce((a, r) => a + num(r?.count), 0) + const share = (c) => { + const pct = sum > 0 ? (c / sum) * 100 : 0 + return pct > 0 && pct < 0.5 ? '< 1 %' : `${Math.round(pct)} %` + } + const all = barRows(items, NAMERS[active]) + const row = (r) => { + const flag = active === 'countries' && flagEmoji(r.key) ? ` ` : '' + return `
  • ${flag}${esc(r.label)}${fmtNum(r.count)}${share(r.count)}
  • ` + } + const tabs = def.tabs.map(([k, label]) => ``).join('') + const rows = all.length ? `
      ${all.slice(0, expanded ? LONG : SHORT).map(row).join('')}
    ` : '

    Noch keine Daten.

    ' + const more = all.length > SHORT && !expanded ? '' : '' + return `

    ${def.title}

    ${tabs}
    ${rows}${more}
    ` +} + +/* --- Datenbasis, Heatmap-Karte, Laden, Fehler --- */ + +export function basisHtml(stats) { + const b = stats?.banner || {} + const decided = num(b.accept) + num(b.reject) + const rate = decided > 0 ? Math.min(1, Math.max(0, isNum(stats?.totals?.consentRate) ? stats.totals.consentRate : num(b.accept) / decided)) : 0 + const pct = Math.round(rate * 100) + const main = decided > 0 + ? `${fmtNum(pct)} % haben dem Banner zugestimmt (${fmtNum(b.accept)} von ${fmtNum(decided)} Entscheidung${decided === 1 ? '' : 'en'}).` + : 'Noch hat niemand über das Banner entschieden.' + const full = 2 * Math.PI * 30 + const ring = `` + return `

    Datenbasis

    ${ring}

    ${main} Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.

    ` +} + +export function heatTeaserHtml() { + return '

    Heatmap

    Sieh auf deiner echten Website, wo geklickt wird – am Computer, Tablet und Handy.

    ' +} + +/** Platzhalter in der Form der echten Kacheln, solange die Auswertung laedt. */ +export function skeletonHtml() { + const card = (cls) => `` + return `
    Auswertung wird geladen
    ${card('an-kpi').repeat(4)}
    ${card('an-chartbox')}${card('an-contacts')}
    ${card('an-rank')}${card('an-rank')}
    ` +} + +export function errorHtml(message) { + return `` +} + +/* --- Reiter Einstellungen, Spec 2.2 --- */ + +const BERLIN_DATE = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) +// Links nur auf eigene Pfade oder http(s) +const hrefOk = (url) => typeof url === 'string' && /^(\/(?!\/)|https?:\/\/)/i.test(url) + +export function settingsHtml(project) { + const a = project?.analyse || {} + const color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' + const since = a.activatedAt ? new Date(a.activatedAt) : null + const status = `Analyse aktiv${since && !Number.isNaN(since.getTime()) ? ` seit ${BERLIN_DATE.format(since)}` : ''}` + const rows = [ + ['Status', esc(status)], + ['Farbe des Banners', ` ${color}`], + a.avvVersion ? ['Vereinbarung', `Fassung ${esc(a.avvVersion)}`] : null, + ].filter(Boolean) + const links = [[a.legal?.avv, 'Auftragsverarbeitungsvertrag Website-Analyse'], [a.legal?.lb, 'Leistungsbeschreibung Website-Analyse']] + .filter(([url]) => hrefOk(url)).map(([url, label]) => `
  • ${label}
  • `) + return `

    Status

    ${rows.map(([k, v]) => `
    ${k}
    ${v}
    `).join('')}
    ` + + `${links.length ? `

    Unterlagen

      ${links.join('')}
    ` : ''}
    ` +} + +/* --- Zusammenbau der Uebersicht --- */ + +export function overviewHtml(stats) { + return `
    ${summaryHtml(stats)}${kpisHtml(stats)}
    ${chartHtml(stats)}${contactsHtml(stats)}
    ` + + `
    ${rankCardHtml('seen', stats)}${rankCardHtml('who', stats)}
    ${basisHtml(stats)}${heatTeaserHtml()}
    ` + + '

    Länderdaten: IP-Geolocation by DB-IP

    ' +} diff --git a/test/analyseCards.test.js b/test/analyseCards.test.js new file mode 100644 index 0000000..2e412da --- /dev/null +++ b/test/analyseCards.test.js @@ -0,0 +1,334 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { headerHtml, tabsHtml, overviewHtml, summaryHtml, kpisHtml, sparkHtml, chartHtml, chartTipText, contactsHtml, rankCardHtml, basisHtml, heatTeaserHtml, skeletonHtml, errorHtml, settingsHtml } from '../public/analyse-cards.js' + +// Testdaten: Form wie analytics/src/stats.js (gleiche Helfer wie test/analyseInsights.test.js) +const isoDay = (from, n) => new Date(Date.UTC(+from.slice(0, 4), +from.slice(5, 7) - 1, +from.slice(8, 10) + n)).toISOString().slice(0, 10) +const days = (from, n, pv) => Array.from({ length: n }, (_, i) => ({ day: isoDay(from, i), pv: typeof pv === 'function' ? pv(i, isoDay(from, i)) : pv })) +const rich = (rows) => rows.map((d) => ({ ...d, visitors: 80, contacts: 6, avgSec: 100 })) +const count = (html, re) => (html.match(re) || []).length +const t = { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 } +const prev = { + from: '2026-08-06', to: '2026-09-04', complete: true, + totals: { pv: 6080, visitors: 2200, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 }, + series: rich(days('2026-08-06', 30, 203)), + contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }, { key: 'wa', count: 23 }], +} +const stats = (over = {}) => ({ + range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01', + totals: t, + series: rich(days('2026-09-05', 30, (i) => (i === 14 ? 371 : 231))), + pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }], + refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }], + countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: '', count: 330 }], + devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2200 }, { key: 't', count: 140 }], + contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'wa', count: 30 }, { key: 'out', count: 10 }], + banner: { shown: 3950, accept: 2410, reject: 1540 }, + previous: prev, + ...over, +}) +const full = stats() +const zeroTotals = { pv: 0, visitors: 0, contacts: 0, consentRate: 0, engaged: 0, avgSec: null, avgScroll: null } +const zero = { + range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '', + totals: zeroTotals, + series: days('2026-09-05', 30, 0).map((d) => ({ ...d, visitors: 0, contacts: 0, avgSec: null })), + pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 0, accept: 0, reject: 0 }, + previous: { from: '2026-08-06', to: '2026-09-04', complete: false, totals: zeroTotals, series: [], contacts: [] }, +} +const OLD = { totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }, { day: '2026-09-18', pv: 30 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } } +const project = (over = {}) => ({ projectId: 'p1', projectName: 'Boulderhalle Überhang', analyse: { state: 'active', devMode: false, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal: { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }, activatedAt: '2026-10-01T22:30:00.000Z' }, ...over }) + +test('overviewHtml: volle Daten – Kacheln mit Chips, Vorzeitraum-Linie, Kontakte, Quote', () => { + const h = overviewHtml(full) + assert.match(h, /▲ 14 %/); assert.match(h, /vs\. 6\.080/); assert.match(h, /class="an-prev"/) + assert.match(h, /Ø Zeit pro Seite/); assert.match(h, /1:48/); assert.match(h, /Ø 64 % gescrollt/) + assert.match(h, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\./) + assert.match(h, /61 % haben dem Banner zugestimmt \(2\.410 von 3\.950 Entscheidungen\)/) + assert.match(h, /Das Wichtigste · 5\. Sep\. – 4\. Okt\./) + assert.match(h, /14 % mehr<\/b>/) // Satz aus summarize + assert.match(h, /Länderdaten: IP-Geolocation by DB-IP<\/a>/) + assert.match(h, /data-rank="seen"/); assert.match(h, /data-rank="who"/); assert.match(h, /data-tab="heatmap"/) + assert.doesNotMatch(h, /undefined|NaN|Infinity|null/) +}) + +test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => { + const h = overviewHtml(OLD) + assert.doesNotMatch(h, /▲|▼|undefined|NaN|null/); assert.doesNotMatch(h, /class="an-prev"/) + assert.doesNotMatch(h, /an-delta/); assert.doesNotMatch(h, /Vergleich ab/) + assert.doesNotMatch(h, /Ø \d+ % gescrollt/) // kein avgScroll + assert.match(h, /612<\/b>/) + assert.equal(count(h, / { + const h = overviewHtml(zero) + assert.match(h, /Sobald ein paar mehr Besuche da sind/); assert.match(h, /Noch hat niemand über das Banner entschieden\./) + assert.match(h, /Noch keine Kontakt-Aktionen\. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt\./) + assert.doesNotMatch(h, /NaN|Infinity|undefined|null/) + assert.doesNotMatch(h, /an-delta|Vergleich ab/) // firstDay '' => kein Datum + assert.match(overviewHtml({}), /Sobald ein paar mehr Besuche da sind/) // sogar eine leere Antwort bricht nichts + assert.doesNotMatch(overviewHtml({}), /NaN|Infinity|undefined|null/) +}) + +test('summaryHtml: Satz und Hinweise mit Symbol; Leerzustand unter 30 Aufrufen', () => { + const h = summaryHtml(full) + assert.match(h, /Deine Website wurde in den letzten 30 Tagen 6\.930-mal<\/b> aufgerufen/) + assert.equal(count(h, /
  • /g), 4); assert.match(h, /
      Das Wichtigste<\/h3>/) // ohne from/to kein Zeitraum +}) + +test('kpisHtml: Chips, Vorwerte, Mini-Verläufe, Zeit mit Scrolltiefe', () => { + const h = kpisHtml(full) + assert.equal(count(h, /class="an-card an-kpi"/g), 4) + assert.equal(count(h, /an-delta up/g), 3); assert.equal(count(h, /an-delta down/g), 1) + assert.match(h, /▲ 10 %/); assert.match(h, /▲ 31 %/); assert.match(h, /▼ 6 s/) + assert.match(h, /vs\. 2\.200/); assert.match(h, /vs\. 143/); assert.match(h, /vs\. 1:54/) + assert.match(h, /aria-label="plus 14 Prozent gegenüber dem Zeitraum davor"/) + assert.match(h, /Besucher\*<\/span>/) + assert.equal(count(h, / ({ ...d, avgSec: i === 0 ? null : 100 })) })) + assert.equal(count(noTime, / d) })), /–<\/b>/); assert.doesNotMatch(none, /gescrollt|vs\. –/) +}) + +test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => { + const h = kpisHtml(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })) + assert.equal(count(h, /Vergleich ab 18\.11\./g), 4) + assert.doesNotMatch(h, /an-delta|vs\./) + const k7 = kpisHtml(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })) + assert.match(k7, /Vergleich ab 11\.10\./) + // vollständiger Vorzeitraum, aber unter 20 Aufrufen: weder Chip noch Datum + const tiny = kpisHtml(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 5 } } })) + assert.doesNotMatch(tiny, /an-delta|vs\.|Vergleich ab/) +}) + +test('kpisHtml: Richtungen – weniger und gleich', () => { + const less = kpisHtml(stats({ totals: { ...t, pv: 5000 } })) + assert.match(less, /▼ 18 %<\/span>/) + const same = kpisHtml(stats({ totals: { ...t, contacts: 143 } })) + assert.match(same, /an-delta flat/); assert.match(same, /± 0/) +}) + +test('sparkHtml: Fläche und Linie, nichts bei weniger als 2 Werten, kein NaN', () => { + const h = sparkHtml([1, 5, 3, 8]) + assert.match(h, / { + const h = chartHtml(full) + assert.match(h, /Seitenaufrufe pro Tag vom 05\.09\. bis 04\.10\., insgesamt 7\.\d{3}<\/title>/) + assert.match(h, /class="an-area"/); assert.match(h, /class="an-line"/); assert.match(h, /class="an-prev"/) + assert.match(h, /30 Tage/); assert.match(h, /Zeitraum davor/) + assert.equal(count(h, /class="an-dot"/g), 30) + assert.match(h, /data-i="14" /); assert.match(h, /data-day="2026-09-19"/); assert.match(h, /data-pv="371"/); assert.match(h, /data-prev="203"/) + assert.match(h, /class="an-tip"/) + // ohne Vergleich: keine gestrichelte Linie, keine zweite Legende, kein data-prev + const nc = chartHtml(stats({ previous: { ...prev, complete: false } })) + assert.doesNotMatch(nc, /class="an-prev"|Zeitraum davor|data-prev/) + assert.match(nc, /30 Tage/) + // ganz ohne Daten + assert.match(chartHtml({ series: [] }), /Noch keine Daten\./) + assert.doesNotMatch(chartHtml(zero), /NaN|Infinity|undefined|null/) +}) + +test('chartTipText: Blase mit und ohne Vorwert', () => { + assert.equal(chartTipText('2026-09-19', 371, 210), 'Sa., 19.09.: 371 Aufrufe (davor 210)') + assert.equal(chartTipText('2026-09-19', '371', '210'), 'Sa., 19.09.: 371 Aufrufe (davor 210)') // Werte aus data-Attributen sind Strings + assert.equal(chartTipText('2026-09-19', 371), 'Sa., 19.09.: 371 Aufrufe') + assert.equal(chartTipText('2026-09-19', 371, null), 'Sa., 19.09.: 371 Aufrufe') + assert.equal(chartTipText('2026-09-19', 1, 0), 'Sa., 19.09.: 1 Aufruf (davor 0)') + assert.equal(chartTipText('2026-09-19', 6930, 6080), 'Sa., 19.09.: 6.930 Aufrufe (davor 6.080)') +}) + +test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => { + const h = contactsHtml(full) + assert.match(h, /188<\/b>/) + assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // jede Zeile hat ihr Symbol + assert.equal(count(h, /96<\/b>[^]*?▲ 16/) // 96 vs 80: beide unter 100 => absolut statt Prozent + assert.match(h, /data-kind="out"[^]*?10<\/b>[^]*?▲ 10/) // 10 vs 0 + // Art ohne Kontakte, in beiden Zeiträumen 0: Zeile entfällt + const noOut = contactsHtml(stats({ contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'out', count: 0 }], totals: { ...t, contacts: 148 }, previous: { ...prev, contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }] } })) + assert.doesNotMatch(noOut, /data-kind="out"|data-kind="wa"|WhatsApp|Links nach außen/) + assert.match(noOut, /data-kind="tel"/); assert.match(noOut, /data-kind="mail"/) + // Vorzeitraum hatte WhatsApp, jetzt 0: Zeile bleibt mit ▼ + const lost = contactsHtml(stats({ contacts: [{ key: 'tel', count: 96 }], totals: { ...t, contacts: 96 } })) + assert.match(lost, /data-kind="wa"[^]*?0<\/b>[^]*?▼ 23/) + // ohne Vergleich: keine Chips, Zeilen nur mit Kontakten + const nc = contactsHtml(stats({ previous: { ...prev, complete: false } })) + assert.doesNotMatch(nc, /an-delta|davor/); assert.match(nc, /data-kind="tel"/) + // Leertext + const none = contactsHtml(zero) + assert.match(none, /Noch keine Kontakt-Aktionen\. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt\./) + assert.doesNotMatch(none, /data-kind|an-quote/) + // 0 Kontakte, aber der Vorzeitraum hatte welche: weiter Leertext (Kopfzahl 0) + assert.match(contactsHtml(stats({ totals: { ...t, contacts: 0 }, contacts: [] })), /Noch keine Kontakt-Aktionen\./) +}) + +test('contactsHtml: Quote pro 100 Besucher, mit und ohne Vorwert, erst ab 30 Besuchern', () => { + assert.match(contactsHtml(full), /

      Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\.<\/p>/) + const nc = contactsHtml(stats({ previous: { ...prev, complete: false } })) + assert.match(nc, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen\./) + const fewPrev = contactsHtml(stats({ previous: { ...prev, totals: { ...prev.totals, visitors: 10 } } })) + assert.match(fewPrev, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen\./) // Vorquote unsicher => ohne "davor" + assert.doesNotMatch(contactsHtml(stats({ totals: { ...t, visitors: 29, contacts: 3 }, contacts: [{ key: 'tel', count: 3 }] })), /an-quote/) + assert.match(contactsHtml(stats({ totals: { ...t, visitors: 400, contacts: 28 } })), /kommen 7,0 Kontakt-Aktionen/) // immer eine Nachkommastelle +}) + +test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => { + const h = rankCardHtml('seen', stats({ pages: [{ key: '/', count: 9 }] })) + assert.match(h, /<img src=x>/); assert.doesNotMatch(h, //) + const pages = rankCardHtml('seen', full) + assert.match(pages, /Startseite/); assert.doesNotMatch(pages, />\/3\.200<\/b>59 %<\/em>/) // 3200 von 5410 + assert.match(pages, /data-rank="seen" data-rank-active="pages"/) + assert.match(pages, /

      Was angesehen wird<\/h3>/) + assert.match(pages, /data-rank-tab="pages" aria-pressed="true"/); assert.match(pages, /data-rank-tab="refs" aria-pressed="false"/) + // Herkunft + const refs = rankCardHtml('seen', full, { tab: 'refs' }) + assert.match(refs, /Google/); assert.match(refs, /Direkt oder unbekannt/); assert.match(refs, /Instagram/); assert.match(refs, /Facebook/) + assert.doesNotMatch(refs, /google\.com|l\.facebook/); assert.match(refs, /data-rank-active="refs"/) + // 6 Zeilen, "Alle anzeigen" nur bei mehr als 6; expanded bis 20 + const many = stats({ pages: Array.from({ length: 25 }, (_, i) => ({ key: `/seite-${i}`, count: 100 - i })) }) + const six = rankCardHtml('seen', many) + assert.equal(count(six, /class="an-lr"/g), 6); assert.match(six, /data-rank-more[^>]*>Alle anzeigen ({ key: `/s${i}`, count: 9 })) })), /Alle anzeigen/) + assert.equal(count(rankCardHtml('seen', stats({ pages: Array.from({ length: 6 }, (_, i) => ({ key: `/s${i}`, count: 9 })) })), /class="an-lr"/g), 6) + assert.match(rankCardHtml('seen', many), /an-fill" style="width:100%"/) // größter Wert = volle Breite +}) + +test('rankCardHtml: Geräte, Länder mit Flagge, ungültiger Reiter, leere Liste, Anteil unter 1 %', () => { + const dev = rankCardHtml('who', full) + assert.match(dev, /

      Wer zu Besuch ist<\/h3>/); assert.match(dev, /Handy/); assert.match(dev, /Computer/); assert.match(dev, /Tablet/) + assert.match(dev, /4\.590<\/b>66 %<\/em>/) + assert.match(rankCardHtml('who', stats({ devices: [{ key: 'x', count: 2 }, { key: 'constructor', count: 1 }] })), /Unbekannt/) + const land = rankCardHtml('who', full, { tab: 'countries' }) + assert.match(land, /🇩🇪/); assert.match(land, /Deutschland/); assert.match(land, /Österreich/); assert.match(land, /Unbekannt/) + assert.match(rankCardHtml('who', full, { tab: 'pages' }), /data-rank-active="devices"/) // Reiter einer anderen Karte => erster Reiter + assert.match(rankCardHtml('seen', stats({ pages: [] })), /Noch keine Daten\./) + assert.equal(rankCardHtml('nope', full), '') + const tiny = rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 10000 }, { key: '/b', count: 1 }] })) + assert.match(tiny, /< 1 %<\/em>/) + assert.doesNotMatch(rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 0 }] })), /NaN|Infinity/) + // Länder-Schlüssel maskiert + const bad = rankCardHtml('who', stats({ countries: [{ key: '', count: 3 }] }), { tab: 'countries' }) + assert.match(bad, /Unbekannt/); assert.doesNotMatch(bad, /<|&/); assert.doesNotMatch(bad, /an-flag/) +}) + +test('basisHtml: Ring, Sätze mit und ohne Entscheidungen', () => { + const h = basisHtml(full) + assert.match(h, /]*>61 %<\/text>/); assert.match(h, /class="an-ring-fg"/) + assert.match(h, /Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite \(\*\) nur bei Zustimmung\./) + const none = basisHtml(zero) + assert.match(none, /Noch hat niemand über das Banner entschieden\./); assert.doesNotMatch(none, /an-ring-fg|haben dem Banner zugestimmt|NaN/) + assert.match(none, />–<\/text>/) + assert.match(basisHtml(stats({ banner: { shown: 1, accept: 1, reject: 0 }, totals: { ...t, consentRate: 1 } })), /\(1 von 1 Entscheidung\)/) + assert.doesNotMatch(basisHtml({}), /NaN|undefined|null/) +}) + +test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => { + const a = project() + const b = { projectId: 'p2', projectName: 'Café "Zum "' } + const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42' }) + assert.doesNotMatch(one, /an-project|Café "Zum <Bären>"<\/option>/) + assert.match(two, /' }) + assert.match(odd, /value="p"1" selected/); assert.match(odd, /heute, <b>x<\/b> Uhr/); assert.doesNotMatch(odd, /x/) + // Zustände ohne Messung sagen nicht "aktiv" + const off = headerHtml({ project: project({ analyse: { state: 'off', eligible: true } }), usable: [], range: 30, showRange: false }) + assert.doesNotMatch(off, /Analyse aktiv/); assert.match(off, /data-state="off"/) + assert.match(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [] }), /Analyse ist abgeschaltet/) + assert.match(headerHtml({ project: project({ analyse: { state: 'off', eligible: false } }), usable: [] }), /Analyse nicht verfügbar/) + assert.doesNotMatch(headerHtml({}), /undefined|null|NaN/) +}) + +test('tabsHtml: tablist, drei Reiter, aria-selected', () => { + const h = tabsHtml('heatmap') + assert.match(h, /role="tablist"/); assert.equal(count(h, /role="tab"/g), 3) + for (const k of ['uebersicht', 'heatmap', 'einstellungen']) assert.match(h, new RegExp(`data-tab="${k}"`)) + assert.match(h, /data-tab="heatmap" aria-selected="true"/); assert.match(h, /data-tab="uebersicht" aria-selected="false"/) + assert.equal(count(h, /aria-selected="true"/g), 1) + assert.match(tabsHtml('quatsch'), /data-tab="uebersicht" aria-selected="true"/) + assert.match(h, />Übersicht<\/button>/); assert.match(h, />Heatmap<\/button>/); assert.match(h, />Einstellungen<\/button>/) +}) + +test('heatTeaserHtml: Knopf wechselt in den Reiter Heatmap', () => { + const h = heatTeaserHtml() + assert.match(h, /