Analyse: HTML-Bausteine der Cockpit-Übersicht
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f119a0cacc
commit
55e98b038c
2 files changed
+607
No files matched your search
@@ -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 <b>), 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=<Reiter>] > 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 = '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>'
|
||||
const ICONS = {
|
||||
contact: PHONE,
|
||||
tel: PHONE,
|
||||
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
wa: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/>',
|
||||
out: '<path d="M7 7h10v10M7 17 17 7"/>',
|
||||
day: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||||
source: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
device: '<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M11 18h2"/>',
|
||||
page: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h6"/>',
|
||||
}
|
||||
const ico = (name) => `<svg class="an-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ''}</svg>`
|
||||
|
||||
// Veraenderungs-Chip aus delta(); ohne Wert nichts
|
||||
const chip = (d) => (d ? `<span class="an-delta ${d.dir}" role="img" aria-label="${esc(d.aria)}">${esc(d.text)}</span>` : '')
|
||||
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
|
||||
? `<select id="an-project" class="an-pick" aria-label="Website wählen">${sites.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project?.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>`
|
||||
: `<b class="an-site-name">${esc(project?.projectName)}</b>`
|
||||
const seg = showRange
|
||||
? `<div class="an-seg" role="group" aria-label="Zeitraum">${RANGES.map((r) => `<button type="button"${r === current ? ' class="on"' : ''} data-range="${r}" aria-pressed="${r === current}">${r} Tage</button>`).join('')}</div>`
|
||||
: ''
|
||||
return `<div class="an-top"><div class="an-site" data-state="${st.s}"><span class="an-status-dot" aria-hidden="true"></span><div>${name}<small class="an-status">${st.text}</small></div></div>${seg}</div>`
|
||||
}
|
||||
|
||||
const TABS = [['uebersicht', 'Übersicht'], ['heatmap', 'Heatmap'], ['einstellungen', 'Einstellungen']]
|
||||
|
||||
export function tabsHtml(tab) {
|
||||
const active = TABS.some(([k]) => k === tab) ? tab : 'uebersicht'
|
||||
return `<div class="an-tabs" role="tablist" aria-label="Bereiche der Analyse">${TABS.map(([k, label]) => `<button type="button" role="tab" class="an-tabbtn${k === active ? ' on' : ''}" id="an-tab-${k}" data-tab="${k}" aria-selected="${k === active}" aria-controls="an-stage">${label}</button>`).join('')}</div>`
|
||||
}
|
||||
|
||||
/* --- 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 = `<h3 class="an-eyebrow">Das Wichtigste${period ? ` · ${esc(period)}` : ''}</h3>`
|
||||
if (!s) return `<section class="an-sum an-sum-empty">${head}<p class="an-lead">Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.</p></section>`
|
||||
// lead und items[].html liefert summarize bereits maskiert
|
||||
const points = s.items.length ? `<ul class="an-points">${s.items.map((i) => `<li><span class="an-point-ico">${ico(i.icon)}</span><span>${i.html}</span></li>`).join('')}</ul>` : ''
|
||||
return `<section class="an-sum">${head}<p class="an-lead">${s.lead}</p>${points}</section>`
|
||||
}
|
||||
|
||||
/** 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 `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${path} L200 38 L0 38 Z"/><path class="an-spark-line" d="${path}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
|
||||
}
|
||||
|
||||
// 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)}<span class="an-vs">vs. ${esc(k.vs)}</span>` : !cmp.ok && cmp.from ? `<span class="an-vs">Vergleich ab ${esc(cmp.from)}</span>` : ''
|
||||
return `<div class="an-card an-kpi" data-kpi="${k.key}"><span class="an-k">${k.label}${k.star ? '<span class="an-star">*</span>' : ''}</span><b class="an-v">${esc(k.value)}</b>${k.sub ? `<small class="an-sub">${esc(k.sub)}</small>` : ''}<div class="an-cmp">${foot}</div>${sparkHtml(trend(stats?.series, field[k.key]))}</div>`
|
||||
}
|
||||
return `<div class="an-kpis">${tiles.map(tile).join('')}</div>`
|
||||
}
|
||||
|
||||
/* --- 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) => `<div class="an-head"><h3>Seitenaufrufe pro Tag</h3>${legend}</div>`
|
||||
if (!series.length) return `<section class="an-card an-chartbox">${head('')}<p class="an-empty">Noch keine Daten.</p></section>`
|
||||
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 = `<div class="an-legend"><span class="an-leg"><i class="an-leg-line"></i>${days} Tage</span>${hasPrev ? '<span class="an-leg"><i class="an-leg-line an-leg-prev"></i>Zeitraum davor</span>' : ''}</div>`
|
||||
const dots = m.points.map((p, i) => `<circle class="an-dot" data-i="${i}" cx="${p.x}" cy="${p.y}" r="3" data-day="${esc(p.day)}" data-pv="${p.pv}"${p.prev === null ? '' : ` data-prev="${p.prev}"`}/>`).join('')
|
||||
return `<section class="an-card an-chartbox">${head(legend)}<div class="an-chartwrap"><svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title"><title id="an-chart-title">${esc(title)}</title>`
|
||||
+ m.yTicks.map((k) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${k.y}" y2="${k.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${k.y + 4}" text-anchor="end">${esc(k.label)}</text>`).join('')
|
||||
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')
|
||||
+ `<path class="an-area" d="${m.area}"/>${hasPrev ? `<path class="an-prev" d="${m.prevLine}"/>` : ''}<path class="an-line" d="${m.line}"/>${dots}</svg><div class="an-tip" hidden></div></div></section>`
|
||||
}
|
||||
|
||||
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 = `<div class="an-head"><h3>Kontakt-Aktionen</h3><b class="an-sum-n">${fmtNum(total)}</b></div>`
|
||||
if (total < 1) return `<section class="an-card an-contacts">${head}<p class="an-empty">Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.</p></section>`
|
||||
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 `<li class="an-crow" data-kind="${key}"><span class="an-cico">${ico(key)}</span><span class="an-cname">${label}</span><b>${fmtNum(cur)}</b>${chip(before && delta(cur, was))}</li>`
|
||||
}).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 ? '' : `<p class="an-quote">Auf 100 Besucher kommen ${rateDe(rate)} Kontakt-Aktionen${wasRate === null ? '' : ` (davor ${rateDe(wasRate)})`}.</p>`
|
||||
return `<section class="an-card an-contacts">${head}${rows ? `<ul class="an-crows">${rows}</ul>` : ''}${quote}</section>`
|
||||
}
|
||||
|
||||
/* --- 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) ? `<span class="an-flag" aria-hidden="true">${flagEmoji(r.key)}</span> ` : ''
|
||||
return `<li class="an-lr"><span class="an-lab"><i class="an-fill" style="width:${r.pct}%" aria-hidden="true"></i><span class="an-name" title="${esc(r.label)}">${flag}${esc(r.label)}</span></span><b>${fmtNum(r.count)}</b><em>${share(r.count)}</em></li>`
|
||||
}
|
||||
const tabs = def.tabs.map(([k, label]) => `<button type="button" class="an-ltab${k === active ? ' on' : ''}" data-rank-tab="${k}" aria-pressed="${k === active}">${label}</button>`).join('')
|
||||
const rows = all.length ? `<ul class="an-lrows">${all.slice(0, expanded ? LONG : SHORT).map(row).join('')}</ul>` : '<p class="an-empty">Noch keine Daten.</p>'
|
||||
const more = all.length > SHORT && !expanded ? '<button type="button" class="an-more" data-rank-more>Alle anzeigen</button>' : ''
|
||||
return `<section class="an-card an-rank" data-rank="${card}" data-rank-active="${active}"><h3>${def.title}</h3><div class="an-ltabs" role="group" aria-label="Ansicht wählen">${tabs}</div>${rows}${more}</section>`
|
||||
}
|
||||
|
||||
/* --- 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 = `<svg class="an-ring" viewBox="0 0 74 74" aria-hidden="true"><circle class="an-ring-bg" cx="37" cy="37" r="30" fill="none" stroke-width="9"/>${rate > 0 ? `<circle class="an-ring-fg" cx="37" cy="37" r="30" fill="none" stroke-width="9" stroke-linecap="round" stroke-dasharray="${round(rate * full)} ${round(full)}" transform="rotate(-90 37 37)"/>` : ''}<text class="an-ring-n" x="37" y="42" text-anchor="middle">${decided > 0 ? `${fmtNum(pct)} %` : '–'}</text></svg>`
|
||||
return `<section class="an-card an-basis"><h3>Datenbasis</h3><div class="an-basis-row">${ring}<p class="an-basis-text"><span class="an-basis-main">${main}</span> Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.</p></div></section>`
|
||||
}
|
||||
|
||||
export function heatTeaserHtml() {
|
||||
return '<section class="an-card an-teaser"><h3>Heatmap</h3><div class="an-teaser-row"><div class="an-mini-heat" aria-hidden="true"></div><p>Sieh auf deiner echten Website, wo geklickt wird – am Computer, Tablet und Handy.</p><button type="button" class="an-btn" data-tab="heatmap">Öffnen</button></div></section>'
|
||||
}
|
||||
|
||||
/** Platzhalter in der Form der echten Kacheln, solange die Auswertung laedt. */
|
||||
export function skeletonHtml() {
|
||||
const card = (cls) => `<div class="an-card ${cls} an-sk" aria-hidden="true"><i></i><i></i><i></i></div>`
|
||||
return `<div class="an-ov an-skeleton" aria-busy="true"><span class="an-sr" role="status">Auswertung wird geladen</span><div class="an-sum an-sk" aria-hidden="true"><i></i><i></i></div><div class="an-kpis">${card('an-kpi').repeat(4)}</div><div class="an-row2">${card('an-chartbox')}${card('an-contacts')}</div><div class="an-pair">${card('an-rank')}${card('an-rank')}</div></div>`
|
||||
}
|
||||
|
||||
export function errorHtml(message) {
|
||||
return `<section class="an-card an-error" role="alert"><p>${esc(message)}</p><button type="button" class="an-btn" id="an-retry">Neu laden</button></section>`
|
||||
}
|
||||
|
||||
/* --- 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', `<span class="an-swatch" style="background:${color}" aria-hidden="true"></span> ${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]) => `<li><a href="${esc(url)}" target="_blank" rel="noopener">${label}</a></li>`)
|
||||
return `<div class="an-ov an-settings"><section class="an-card an-status-card"><h3>Status</h3><dl class="an-dl">${rows.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('')}</dl></section>`
|
||||
+ `${links.length ? `<section class="an-card an-docs"><h3>Unterlagen</h3><ul>${links.join('')}</ul></section>` : ''}<section class="an-card" id="an-off"></section></div>`
|
||||
}
|
||||
|
||||
/* --- Zusammenbau der Uebersicht --- */
|
||||
|
||||
export function overviewHtml(stats) {
|
||||
return `<div class="an-ov">${summaryHtml(stats)}${kpisHtml(stats)}<div class="an-row2">${chartHtml(stats)}${contactsHtml(stats)}</div>`
|
||||
+ `<div class="an-pair">${rankCardHtml('seen', stats)}${rankCardHtml('who', stats)}</div><div class="an-pair">${basisHtml(stats)}${heatTeaserHtml()}</div>`
|
||||
+ '<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p></div>'
|
||||
}
|
||||
@@ -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, /<b>14 % mehr<\/b>/) // Satz aus summarize
|
||||
assert.match(h, /Länderdaten: IP-Geolocation by <a href="https:\/\/db-ip\.com" target="_blank" rel="noopener">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, /<b class="an-v">612<\/b>/)
|
||||
assert.equal(count(h, /<svg class="an-spark"/g), 1) // nur Seitenaufrufe haben eine Reihe (series hat nur pv)
|
||||
})
|
||||
|
||||
test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => {
|
||||
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 <b>6\.930-mal<\/b> aufgerufen/)
|
||||
assert.equal(count(h, /<li>/g), 4); assert.match(h, /<svg class="an-ico"/)
|
||||
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // alle Symbole der Hinweise sind belegt
|
||||
assert.match(h, /188-mal hat dich jemand direkt kontaktiert, meist per Telefon \(96\)\./)
|
||||
const few = summaryHtml(stats({ totals: { ...t, pv: 12 } }))
|
||||
assert.match(few, /Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen\./)
|
||||
assert.doesNotMatch(few, /<ul/)
|
||||
assert.match(summaryHtml({ totals: { pv: 3 } }), /<h3 class="an-eyebrow">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 class="an-star">\*<\/span>/)
|
||||
assert.equal(count(h, /<svg class="an-spark"/g), 4)
|
||||
// Verlauf der Zeit: fehlende Tageswerte (null) zählen als 0
|
||||
const noTime = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, avgSec: i === 0 ? null : 100 })) }))
|
||||
assert.equal(count(noTime, /<svg class="an-spark"/g), 4)
|
||||
// Reihe ohne avgSec-Feld: kein Verlauf bei der Zeit
|
||||
assert.equal(count(kpisHtml(stats({ series: full.series.map(({ avgSec, ...d }) => d) })), /<svg class="an-spark"/g), 3)
|
||||
// keine Zeitmessung: Strich statt "0:00"
|
||||
const none = kpisHtml(stats({ totals: { ...t, avgSec: null, avgScroll: null }, previous: { ...prev, totals: { ...prev.totals, avgSec: null } } }))
|
||||
assert.match(none, /<b class="an-v">–<\/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, /<span class="an-delta down" role="img" aria-label="minus 18 Prozent gegenüber dem Zeitraum davor">▼ 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, /<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none"/)
|
||||
assert.match(h, /class="an-spark-area"/); assert.match(h, /class="an-spark-line"/)
|
||||
assert.equal(sparkHtml([4]), ''); assert.equal(sparkHtml([]), ''); assert.equal(sparkHtml(undefined), '')
|
||||
assert.doesNotMatch(sparkHtml([0, 0, 0]), /NaN|Infinity/)
|
||||
assert.doesNotMatch(sparkHtml([3, null, undefined, 'x']), /NaN|undefined|null/)
|
||||
})
|
||||
|
||||
test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte mit Daten', () => {
|
||||
const h = chartHtml(full)
|
||||
assert.match(h, /<title id="an-chart-title">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, /<b class="an-sum-n">188<\/b>/)
|
||||
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // jede Zeile hat ihr Symbol
|
||||
assert.equal(count(h, /<svg class="an-ico"/g), 4)
|
||||
for (const label of ['Anrufe', 'E-Mails', 'WhatsApp', 'Links nach außen']) assert.match(h, new RegExp(label))
|
||||
assert.match(h, /data-kind="tel"[^]*?<b>96<\/b>[^]*?▲ 16/) // 96 vs 80: beide unter 100 => absolut statt Prozent
|
||||
assert.match(h, /data-kind="out"[^]*?<b>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"[^]*?<b>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), /<p class="an-quote">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: '/<img src=x>', count: 9 }] }))
|
||||
assert.match(h, /<img src=x>/); assert.doesNotMatch(h, /<img src=x>/)
|
||||
const pages = rankCardHtml('seen', full)
|
||||
assert.match(pages, /Startseite/); assert.doesNotMatch(pages, />\/</)
|
||||
assert.match(pages, /<b>3\.200<\/b><em>59 %<\/em>/) // 3200 von 5410
|
||||
assert.match(pages, /data-rank="seen" data-rank-active="pages"/)
|
||||
assert.match(pages, /<h3>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</)
|
||||
const open = rankCardHtml('seen', many, { expanded: true })
|
||||
assert.equal(count(open, /class="an-lr"/g), 20); assert.doesNotMatch(open, /Alle anzeigen/)
|
||||
assert.doesNotMatch(rankCardHtml('seen', stats({ pages: Array.from({ length: 6 }, (_, i) => ({ 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, /<h3>Wer zu Besuch ist<\/h3>/); assert.match(dev, /Handy/); assert.match(dev, /Computer/); assert.match(dev, /Tablet/)
|
||||
assert.match(dev, /<b>4\.590<\/b><em>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, /<em>< 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: '<b>', count: 3 }] }), { tab: 'countries' })
|
||||
assert.match(bad, /Unbekannt/); assert.doesNotMatch(bad, /<b><|<b>&/); assert.doesNotMatch(bad, /an-flag/)
|
||||
})
|
||||
|
||||
test('basisHtml: Ring, Sätze mit und ohne Entscheidungen', () => {
|
||||
const h = basisHtml(full)
|
||||
assert.match(h, /<text class="an-ring-n"[^>]*>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 <Bären>"' }
|
||||
const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42' })
|
||||
assert.doesNotMatch(one, /an-project|<select/); assert.match(one, /<b class="an-site-name">Boulderhalle Überhang<\/b>/)
|
||||
assert.match(one, /Analyse aktiv · Stand: heute, 19:42 Uhr/)
|
||||
assert.match(one, /<button type="button" class="on" data-range="30" aria-pressed="true">30 Tage<\/button>/)
|
||||
assert.equal(count(one, /data-range="/g), 3); assert.match(one, /data-range="7"[^>]*aria-pressed="false"/)
|
||||
const two = headerHtml({ project: b, usable: [a, b], range: 7, showRange: false, devMode: false, stamp: '' })
|
||||
assert.match(two, /<select id="an-project"/); assert.match(two, /<option value="p2" selected>Café "Zum <Bären>"<\/option>/)
|
||||
assert.match(two, /<option value="p1">Boulderhalle Überhang<\/option>/)
|
||||
assert.doesNotMatch(two, /data-range/); assert.doesNotMatch(two, /Zum <Bären>/); assert.doesNotMatch(two, /Stand:/)
|
||||
assert.match(two, /Analyse aktiv/)
|
||||
assert.match(headerHtml({ project: a, usable: [a], range: 365, showRange: true }), /class="on" data-range="30"/) // unbekannter Zeitraum => 30
|
||||
// Dev-Modus
|
||||
const dev = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: true, stamp: '19:42' })
|
||||
assert.match(dev, /Im Dev-Modus wird nicht gemessen\./); assert.doesNotMatch(dev, /Analyse aktiv|Stand:/); assert.match(dev, /data-state="warn"/)
|
||||
assert.match(one, /data-state="on"/)
|
||||
// Name maskiert
|
||||
const evil = headerHtml({ project: { projectId: 'x', projectName: '<img src=x onerror=alert(1)>' }, usable: [], range: 30, showRange: false })
|
||||
assert.doesNotMatch(evil, /<img/); assert.match(evil, /<img src=x onerror=alert\(1\)>/)
|
||||
const odd = headerHtml({ project: { projectId: 'p"1', projectName: 'A' }, usable: [{ projectId: 'p"1', projectName: 'A' }, { projectId: 'p2', projectName: 'B' }], stamp: '<b>x</b>' })
|
||||
assert.match(odd, /value="p"1" selected/); assert.match(odd, /heute, <b>x<\/b> Uhr/); assert.doesNotMatch(odd, /<b>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, /<button type="button" class="an-btn" data-tab="heatmap">Öffnen<\/button>/); assert.match(h, /<h3>Heatmap<\/h3>/)
|
||||
assert.match(h, /an-mini-heat/)
|
||||
})
|
||||
|
||||
test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {
|
||||
const h = settingsHtml(project())
|
||||
assert.match(h, /aktiv seit 02\.10\.2026/) // 22:30 UTC = 00:30 Berlin am 02.10.
|
||||
assert.match(h, /class="an-swatch" style="background:#1d4ed8"/); assert.match(h, /#1d4ed8/)
|
||||
assert.match(h, /Fassung 2026-10-02/)
|
||||
assert.match(h, /<a href="\/rechtliches\/avv-website-analyse-2026-10-02\.html" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse<\/a>/)
|
||||
assert.match(h, /<a href="\/rechtliches\/leistungsbeschreibung-website-analyse-2026-10-02\.html" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse<\/a>/)
|
||||
assert.match(h, /<section class="an-card" id="an-off"><\/section>/)
|
||||
// Farbe ungültig => #111111; kein Datum => ohne "seit"
|
||||
const bad = settingsHtml(project({ analyse: { state: 'active', accentColor: 'red;"><x>', avvVersion: '2026-10-02', legal: {}, activatedAt: null } }))
|
||||
assert.match(bad, /background:#111111/); assert.doesNotMatch(bad, /<x>|seit|<a /)
|
||||
assert.match(bad, /id="an-off"/)
|
||||
// gefährliche Link-Ziele und Namen
|
||||
const evil = settingsHtml(project({ analyse: { state: 'active', accentColor: '#fff', avvVersion: '<b>', legal: { avv: 'javascript:alert(1)', lb: '/x"onmouseover="y' }, activatedAt: 'kaputt' } }))
|
||||
assert.doesNotMatch(evil, /javascript:|<b>|onmouseover="y/); assert.doesNotMatch(evil, /NaN|undefined|null|Invalid/)
|
||||
assert.doesNotMatch(settingsHtml({ projectId: 'p' }), /NaN|undefined|null|Invalid/)
|
||||
})
|
||||
|
||||
test('errorHtml und skeletonHtml', () => {
|
||||
assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/)
|
||||
assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /Auswertung gerade nicht erreichbar/); assert.match(errorHtml('x'), /role="alert"/); assert.match(errorHtml('x'), />Neu laden</)
|
||||
assert.match(errorHtml('<img src=x>'), /<img src=x>/); assert.doesNotMatch(errorHtml('<img src=x>'), /<img/)
|
||||
const sk = skeletonHtml()
|
||||
assert.equal(count(sk, /class="an-card an-kpi an-sk"/g), 4); assert.match(sk, /aria-busy="true"/)
|
||||
assert.doesNotMatch(sk, /Lade…|undefined|NaN/)
|
||||
})
|
||||
|
||||
test('Alle Bausteine: Werte aus Daten werden maskiert (Pfade, Hosts, Tage, Länder)', () => {
|
||||
const evil = '"><script>alert(1)</script>'
|
||||
const s = stats({
|
||||
pages: [{ key: evil, count: 5 }],
|
||||
refs: [{ key: evil, count: 5 }],
|
||||
countries: [{ key: evil, count: 5 }],
|
||||
devices: [{ key: evil, count: 5 }],
|
||||
series: [{ day: evil, pv: 5 }, { day: '2026-09-18', pv: 7 }],
|
||||
previous: { ...prev, series: [{ pv: 3 }, { pv: 4 }] },
|
||||
})
|
||||
const all = overviewHtml(s) + rankCardHtml('seen', s, { tab: 'refs' }) + rankCardHtml('who', s, { tab: 'countries' }) + rankCardHtml('who', s) + chartHtml(s)
|
||||
assert.doesNotMatch(all, /<script>/)
|
||||
assert.match(all, /<script>/)
|
||||
})
|
||||
Reference in new issue
Block a user