Final-Review-Fixwelle der Cockpit-Übersicht:
- F1 Collector: Der Zeitraum sind die range Kalendertage bis gestern (Berliner Tag),
der Vorzeitraum die range Tage davor; heute steht getrennt in today { day, pv, visitors,
contacts }. Kein Einbruch mehr am rechten Rand von Diagramm und Mini-Verläufen,
kein falsches "x % weniger" tagsüber. Portal: weekdayStats bekommt die ganze Reihe
(completedDays entfällt), "Vergleich ab" = firstDay + 2 x range, Kopfzeile
"Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr" (alter Collector ohne today
bleibt ohne "Heute bisher").
- F2 "direkt kontaktiert" zählt nur Telefon, E-Mail und WhatsApp (auch im Vorzeitraum),
Links nach außen nicht.
- F3 Mini-Verlauf "Ø Zeit pro Seite": Tage ohne Messung sind Lücken statt 0-Tälern.
- F4 Fehlerfarbe beim Ausschalten rot, --an-faint auf 55 %, Achsenschrift per
Container-Abfragen auf 11-12 px (y-Rand wächst mit der Stellenzahl, x-Beschriftungen mit
Mindestabstand), Ranglisten-Anteile an allen Seitenaufrufen, Tippflächen bis 880 px
mindestens 40 px hoch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
23 KiB
JavaScript
298 lines
23 KiB
JavaScript
/* 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 "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr") + .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, 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 = '<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 "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) => `<span class="an-st">${t}</span>`).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
|
||
? `<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; 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 `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${area}"/><path class="an-spark-line" d="${line}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
|
||
}
|
||
|
||
// 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)}<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], k.key === 'time'))}</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>`
|
||
// 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 = `<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}" dy="0.35em" text-anchor="end">${esc(k.label)}</text>`).join('')
|
||
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 9}" 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 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) ? `<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 = 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', `<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>'
|
||
}
|