/* 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 ), 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, 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 `
${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; 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)}
${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 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) ? ` ` : '' 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 ? `

    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

    ' }