] > 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, bannerColor } from './analyse-view.js'
const RANGES = [7, 30, 90]
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 "Heute bisher" aus stats.today und Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand
function statusOf(project, devMode, stamp, today) {
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') {
const pv = isNum(today?.pv) ? today.pv : null // alter Collector ohne today: kein "Heute bisher"
// jeder Teil bricht nur als Ganzes in die naechste Zeile (kein "Stand:" allein am Zeilenende)
const parts = ['Analyse aktiv', pv === null ? '' : `Heute bisher: ${fmtNum(pv)} ${pv === 1 ? 'Aufruf' : 'Aufrufe'}`, stamp ? `Stand: ${stamp} Uhr` : '']
return { s: 'on', text: parts.filter(Boolean).map((t) => `${t}`).join(' · ') }
}
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, today: stats.today (heute bisher). */
export function headerHtml({ project, usable, range, showRange, devMode, stamp, today } = {}) {
const sites = list(usable)
const st = statusOf(project, devMode, stamp ? esc(stamp) : '', today)
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 ``
}
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; null/undefined ist eine Luecke (Tag ohne Messung): die Linie bricht dort ab, es wird nie als 0 gezeichnet. Unter 2 Werten nichts. */
export function sparkHtml(values) {
const v = list(values).map((x) => (x === null || x === undefined ? null : num(x)))
if (v.length < 2 || v.filter((x) => x !== null).length < 2) return ''
const max = Math.max(1, ...v.filter((x) => x !== null)) * 1.1
const x = (i) => round((200 * i) / (v.length - 1))
const pt = (y, i) => `${x(i)} ${round(36 - (32 * y) / max)}`
// zusammenhaengende Abschnitte ohne Luecke: [[wert, index], ...]
const runs = []
v.forEach((y, i) => {
if (y === null) runs.push(null)
else if (runs.length && runs[runs.length - 1]) runs[runs.length - 1].push([y, i])
else runs.push([[y, i]])
})
const parts = runs.filter(Boolean)
const seg = (r) => r.map(([y, i], k) => `${k ? 'L' : 'M'}${pt(y, i)}`).join(' ')
// ein einzelner Tag zwischen Luecken wird ein Punkt (Strich der Laenge 0, runde Linienenden)
const line = parts.map((r) => (r.length === 1 ? `${seg(r)} L${pt(...r[0])}` : seg(r))).join(' ')
const area = parts.filter((r) => r.length > 1).map((r) => `${seg(r)} L${x(r[r.length - 1][1])} 38 L${x(r[0][1])} 38 Z`).join(' ')
return ``
}
// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null.
// gaps: Tage ohne Wert bleiben null (Luecke im Verlauf), z.B. die Zeit pro Seite an Tagen ohne Zustimmung
const trend = (series, key, gaps = false) => {
const rows = list(series)
return rows.some((d) => Object.hasOwn(d ?? {}, key)) ? rows.map((d) => (gaps && !isNum(d?.[key]) ? null : 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], k.key === 'time'))}
`
}
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.
`
// Schrift der Achsen in viewBox-Einheiten: bis ca. 24 auf dem schmalsten Handy (analyse.css). Der linke Rand haelt die breiteste
// y-Beschriftung (Ziffern ca. 0,6 x 24 breit), die x-Beschriftungen halten 100 Einheiten Abstand (eine Datumsbeschriftung ist bei Schrift 28 ca. 92 breit)
const opts = { height: 300, maxLabels: 5, labelGap: 100, compare: cmp.ok ? stats.previous.series : undefined }
const padL = Math.max(40, Math.round(6 + String(chartModel(series, opts).top).length * 14.4))
const m = chartModel(series, { ...opts, pad: { l: padL, r: 24, t: 12, b: 40 } })
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)} `
}
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 ``
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 ``
}
/* --- 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 listSum = items.reduce((a, r) => a + num(r?.count), 0)
// Anteil an allen Seitenaufrufen (die Liste zeigt hoechstens 20 Eintraege); Herkunft kennt nur die Summe der Liste (Besucher ohne Zustimmung haben keine)
const sum = active === 'refs' ? listSum : Math.max(listSum, num(stats?.totals?.pv))
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) ? `${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 = bannerColor(a.accentColor) // wie auf der Website: zu helle Farbe => #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 ? `
` : ''}
`
}
/* --- 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
'
}