From 5a6fbc7328744ba5da1d8b8362331cf7ea36f39f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:24:48 +0000 Subject: [PATCH] Analyse: Portal-Kachel und Auswertung Co-Authored-By: Claude Sonnet 5.5 --- public/analyse-view.js | 83 ++++++++++++ public/app.js | 5 +- public/dashboard.html | 9 ++ public/funktionen.css | 63 +++++++++ public/funktionen.js | 268 ++++++++++++++++++++++++++++++++++++++- test/analyseView.test.js | 59 +++++++++ 6 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 public/analyse-view.js create mode 100644 test/analyseView.test.js diff --git a/public/analyse-view.js b/public/analyse-view.js new file mode 100644 index 0000000..bb99af0 --- /dev/null +++ b/public/analyse-view.js @@ -0,0 +1,83 @@ +/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ + +// Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1 +export function contrastOk(hex) { + if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false + const lin = (i) => { + const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255 + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + } + const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2) + return 1.05 / (L + 0.05) >= 4.5 +} + +export const FALLBACK_COLOR = '#111111' +/** Farbe der beiden Banner-Knoepfe: die Akzentfarbe, wenn sie genug Kontrast hat, sonst #111111. */ +export const bannerColor = (hex) => (contrastOk(hex) ? String(hex).toLowerCase() : FALLBACK_COLOR) + +export const DEVICE_LABELS = { m: 'Handy', t: 'Tablet', d: 'Computer' } +export const CONTACT_LABELS = { tel: 'Telefon', mail: 'E-Mail', wa: 'WhatsApp', out: 'Externer Link' } + +/** Flaggen-Emoji aus ISO-3166-Alpha-2-Code ('' bei ungueltigem Code). */ +export function flagEmoji(code) { + if (!/^[A-Za-z]{2}$/.test(code || '')) return '' + return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0))) +} + +let regionNames +/** "DE" => "Deutschland"; '' => "Unbekannt"; unbekannter Code => der Code selbst. */ +export function countryName(code) { + if (!/^[A-Za-z]{2}$/.test(code || '')) return 'Unbekannt' + try { + regionNames ||= new Intl.DisplayNames('de', { type: 'region' }) + return regionNames.of(code.toUpperCase()) || code.toUpperCase() + } catch { + return code.toUpperCase() + } +} + +/** Herkunft: '' => "Direkt". */ +export const refLabel = (key) => key || 'Direkt' + +/** Hoehe der Skala: naechste "runde" Zahl (1, 2, 5 x 10^n) ueber dem Maximum. */ +export function niceMax(max) { + if (!(max > 0)) return 1 + const pow = 10 ** Math.floor(Math.log10(max)) + for (const m of [1, 2, 5, 10]) if (m * pow >= max) return m * pow + return 10 * pow +} + +/** "2026-10-01" => "01.10." */ +export function shortDay(day) { + const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(day || '') + return m ? `${m[2]}.${m[1]}.` : String(day || '') +} + +/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */ +export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) { + const data = Array.isArray(series) ? series : [] + const innerW = width - pad.l - pad.r + const innerH = height - pad.t - pad.b + const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0))) + const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2) + const y = (v) => pad.t + innerH - (innerH * v) / top + const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0 })) + const step = Math.max(1, Math.ceil(data.length / maxLabels)) + const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > 36).map((p) => ({ x: p.x, label: shortDay(p.day) })) + const yTicks = [0, 0.5, 1].map((f) => ({ y: round(y(top * f)), label: String(Math.round(top * f)) })) + const line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ') + const baseY = round(pad.t + innerH) + const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : '' + return { width, height, pad, top, points, xLabels, yTicks, line, area } +} + +function round(n) { + return Math.round(n * 10) / 10 +} + +/** Zeilen fuer eine Balkenliste: [{label, count, pct}] mit pct relativ zum groessten Wert (0-100). */ +export function barRows(items, labelOf) { + const list = (Array.isArray(items) ? items : []).map((i) => ({ key: i.key ?? '', count: Number(i.count) || 0 })) + const max = Math.max(1, ...list.map((i) => i.count)) + return list.map((i) => ({ key: i.key, label: labelOf(i.key), count: i.count, pct: Math.round((i.count / max) * 100) })) +} diff --git a/public/app.js b/public/app.js index c8dd3e2..c14c643 100644 --- a/public/app.js +++ b/public/app.js @@ -1961,13 +1961,14 @@ function initPasswordView() { // "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht. // "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt // "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe). -const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' } +const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' } const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' } const viewLoaders = { chat: initChat } // Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden const viewRefreshers = { funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))), news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))), + analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))), } const loadedViews = new Set() @@ -2157,7 +2158,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and ( const coarsePointer = window.matchMedia('(pointer: coarse)') // Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil). // "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde. -const APP_PARENT = { news: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' } +const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' } const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' } const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null } const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])' diff --git a/public/dashboard.html b/public/dashboard.html index dfe0b7a..1d2f400 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -176,6 +176,15 @@

Lade…

+ +
+
+

Analyse

+

Sieh, wie deine Website genutzt wird – ohne Cookies.

+
+

Lade…

+
+
diff --git a/public/funktionen.css b/public/funktionen.css index d3078ec..40487d8 100644 --- a/public/funktionen.css +++ b/public/funktionen.css @@ -137,3 +137,66 @@ .fn-tiles { grid-template-columns: minmax(0, 1fr); } .fn-facts dt { min-width: 0; flex: 1 0 100%; } } + +/* ------------------------------------------------------------- Analyse --- */ +#view-analyse .kb-panel { max-width: none; } +#view-analyse .an-panel { max-width: 720px; } +#view-analyse .fn-project { max-width: 640px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; } +#view-analyse .kb-panel + .kb-panel { margin-top: 16px; } +.an-panel p { margin: 0 0 12px; line-height: 1.55; } +.an-h4 { margin: 18px 0 8px; font-size: 14px; } +.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } +.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: transparent; cursor: pointer; } +.an-presets { display: flex; flex-wrap: wrap; gap: 8px; } +.an-preset { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--kb-border-strong); cursor: pointer; padding: 0; } +.an-hint { margin: 10px 0 0; font-size: 13px; color: #fcd34d; } +/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */ +.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } +.an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; } +.an-bn-link { text-decoration: underline; } +.an-bn-row { display: flex; gap: 10px; flex: none; } +.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 120px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } + +.an-range { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.an-tab { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; } +.an-tab.active { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); } +.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } +.an-kpi { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); min-width: 0; } +.an-kpi span { display: block; font-size: 13px; color: var(--kb-muted); } +.an-kpi b { display: block; margin-top: 4px; font-size: 26px; font-variant-numeric: tabular-nums; } +.an-chart-box { margin-bottom: 16px; } +.an-chart { display: block; width: 100%; height: auto; } +.an-grid { stroke: var(--kb-border-strong); stroke-width: 1; } +.an-axis { fill: var(--kb-muted); font-size: 11px; } +.an-line { fill: none; stroke: var(--kb-lilac); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; } +.an-area { fill: var(--kb-purple-soft); stroke: none; } +.an-dot { fill: var(--kb-lilac); } +.an-grid-lists { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; align-items: start; } +#view-analyse .an-grid-lists .kb-panel { margin-top: 0; } +.an-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } +.an-row-top { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; } +.an-row-label { min-width: 0; overflow-wrap: anywhere; } +.an-row-top b { font-variant-numeric: tabular-nums; } +.an-bar { height: 6px; margin-top: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.07); overflow: hidden; } +.an-bar i { display: block; height: 100%; border-radius: 3px; background: var(--kb-purple); } +.an-note, .an-credit { margin: 16px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--kb-muted); } +.an-credit { font-size: 11.5px; color: var(--kb-faint); } +.an-credit a { color: inherit; } +.an-heat { margin-top: 16px; } +.an-heat p, .an-off p { margin: 0 0 12px; line-height: 1.55; } +.an-heat-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; } +.an-heat-row label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; flex: 1 1 200px; min-width: 0; } +.an-heat-row select { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; min-width: 0; } +.an-heat-row select option { color: #111; } +.an-off { margin-top: 16px; } +.an-ghost { padding: 9px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; } +.an-ghost:hover { background: rgba(255, 255, 255, 0.07); } +.an-danger { color: #fca5a5; } + +@media (max-width: 880px) { + .an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .an-grid-lists { grid-template-columns: minmax(0, 1fr); } + .an-bn { display: block; margin: 12px 0; padding: 16px; } + .an-bn-row { flex-direction: column; margin-top: 12px; } + .an-bn-btn { width: 100%; } +} diff --git a/public/funktionen.js b/public/funktionen.js index 7a425b0..082aa5d 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -3,7 +3,10 @@ Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css. Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ +import { bannerColor, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' + const PRICE = '30 €' +const AVV_URL = '/rechtliches/avv-website-analyse' // Platzhalter: das Dokument liefert Task 13 const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um const PHOTO_MAX_BYTES = 10 * 1024 * 1024 @@ -95,6 +98,38 @@ function newsTile(projects) { ` } +/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ +const analyseOf = (project) => project.analyse || { comingSoon: true } +const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') +const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.' + +function pickAnalyseProject(projects) { + return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0] +} + +function analyseTile(projects) { + const target = pickAnalyseProject(projects) + const a = target && analyseOf(target) + if (!target || a.comingSoon) return soonTile('chart', 'Analyse', ANALYSE_TEXT) + const state = analyseState(a) + const active = projects.filter((p) => analyseState(analyseOf(p)) === 'active').length + const pill = state === 'active' ? ['Aktiv', 'on'] : ['Aus', 'off'] + let action + if (state === 'active') action = `` + else if (state === 'off') action = `` + else if (a.reason === 'kein_abo') action = '

Im Wartungs-Abo enthalten.

' + else action = `

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}

` + const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' + return ` +
+
${svg('chart')}${pill[0]}
+

Analyse

+

${ANALYSE_TEXT} Ohne Cookies, auf einem Server in Deutschland.

+ ${count} + ${action} +
` +} + const soonTile = (icon, title, text) => `
${svg(icon)}Kommt bald
@@ -109,9 +144,10 @@ export async function initFunktionen(root) {
${newsTile(projects)} ${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')} - ${soonTile('chart', 'Analyse', 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.')} + ${analyseTile(projects)}
` - root.querySelector('.fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project }) + root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project }) + root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project }) } /* ---------------------------------------------------------------- News --- */ @@ -505,3 +541,231 @@ function renderEditor(stage, project) { sync() return loadList() } + +/* -------------------------------------------------------------- Analyse --- */ +const analyseSel = { projectId: '', range: 30 } +const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] +const HEX_RE = /^#[0-9a-f]{6}$/i +const nf = new Intl.NumberFormat('de-DE') +const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.' + +export async function initAnalyse(root, { projectId } = {}) { + if (!root) return + if (projectId) analyseSel.projectId = projectId + const { projects = [] } = await request('/api/funktionen') + const usable = projects.filter((p) => !analyseOf(p).comingSoon) + if (!usable.length) { + root.innerHTML = '
Die Analyse ist für deine Websites noch nicht freigeschaltet.
' + return + } + const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) + analyseSel.projectId = project.projectId + root.innerHTML = ` + ${usable.length > 1 ? ` +
+ + +
` : ''} +
` + root.querySelector('#an-project')?.addEventListener('change', (ev) => { + analyseSel.projectId = ev.target.value + initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) + }) + const stage = root.querySelector('#an-stage') + const a = analyseOf(project) + const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) + if (a.state === 'active') renderAnalyseStats(stage, project, reload) + else if (a.state === 'disabled') stage.innerHTML = '

Analyse ist abgeschaltet

Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

' + else if (!a.eligible) stage.innerHTML = `

Analyse nicht verfügbar

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}

` + else renderAnalyseActivation(stage, project, reload) +} + +function bannerPreview(color) { + const c = bannerColor(color) + const btn = `style="background:${c};border-color:${c};color:#fff"` + return ` + ` +} + +function renderAnalyseActivation(stage, project, reload) { + const a = analyseOf(project) + let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' + stage.innerHTML = ` +
+

Analyse für ${esc(project.projectName)} einschalten

+

Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen. Wir speichern keine IP-Adressen und setzen keine Cookies. Alles läuft auf einem eigenen Server in Deutschland.

+
    +
  • Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.
  • +
  • Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.
  • +
  • Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.
  • +
+

Farbe des Banners

+
+ +
${PRESET_COLORS.map((c) => ``).join('')}
+
+ +
+ + +
+
` + const input = stage.querySelector('#an-color') + const hint = stage.querySelector('#an-hint') + const paint = () => { + stage.querySelector('#an-preview').innerHTML = bannerPreview(color) + hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) + } + paint() + input.addEventListener('input', () => { color = input.value; paint() }) + stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) + const box = stage.querySelector('#an-avv') + const button = stage.querySelector('#an-on') + const error = stage.querySelector('#an-error') + box.addEventListener('change', () => { button.disabled = !box.checked }) + button.addEventListener('click', async () => { + if (!box.checked) return + button.disabled = true + error.classList.add('hidden') + try { + await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) + reload() + } catch (err) { + error.textContent = err.message + error.classList.remove('hidden') + button.disabled = !box.checked + } + }) +} + +function lineChart(series) { + const m = chartModel(series) + if (!m.points.length) return '

Noch keine Daten.

' + const total = m.points.reduce((sum, p) => sum + p.pv, 0) + const first = m.points[0].day + const last = m.points[m.points.length - 1].day + return ` + + Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))} + ${m.yTicks.map((t) => `${esc(t.label)}`).join('')} + ${m.xLabels.map((l) => `${esc(l.label)}`).join('')} + + + ${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))}`).join('') : ''} + ` +} + +function barList(title, items, labelOf, { html = false } = {}) { + const rows = barRows(items, labelOf) + return ` +
+

${esc(title)}

+ ${rows.length ? `
    ${rows.map((r) => ` +
  • ${html ? r.label : esc(r.label)}${esc(nf.format(r.count))}
  • `).join('')}
` : '

Noch keine Daten.

'} +
` +} + +const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${esc(countryName(key))}` + +async function renderAnalyseStats(stage, project, reload) { + const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30 + const pid = project.projectId + stage.innerHTML = ` +
${[7, 30, 90].map((r) => ``).join('')}
+

Lade…

+
+
+

Länderdaten: IP-Geolocation by DB-IP

` + stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) })) + renderAnalyseOff(stage.querySelector('#an-off'), pid, reload) + + const data = stage.querySelector('#an-data') + let stats + try { + stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) + } catch (err) { + if (!stage.isConnected) return + data.innerHTML = `` + return + } + if (!stage.isConnected) return + const t = stats.totals || {} + const rate = Math.round((Number(t.consentRate) || 0) * 100) + const kpi = (label, value) => `
${label}${esc(value)}
` + const b = stats.banner || {} + data.innerHTML = ` +
${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}
+

Seitenaufrufe pro Tag

${lineChart(stats.series)}
+
+ ${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')} + ${barList('Herkunft', stats.refs, refLabel)} + ${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })} + ${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')} + ${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)} + ${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))} +
+

${esc(KPI_NOTE)}

` + renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) +} + +function renderAnalyseHeat(box, projectId, pages) { + if (!box) return + if (!pages.length) { + box.innerHTML = '

Heatmap

Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

' + return + } + box.innerHTML = ` +

Heatmap

+

Sieh auf deiner Website, wo Besucher klicken und wie weit sie scrollen.

+
+ + +
+ +
` + const go = box.querySelector('#an-heat-go') + const error = box.querySelector('#an-heat-error') + go.addEventListener('click', async () => { + go.disabled = true + error.classList.add('hidden') + try { + const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path: box.querySelector('#an-heat-page').value || '/', vp: box.querySelector('#an-heat-vp').value } }) + if (safeUrl(url)) window.open(url, '_blank', 'noopener') + } catch (err) { + error.textContent = err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message + error.classList.remove('hidden') + } finally { + go.disabled = false + } + }) +} + +// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog +function renderAnalyseOff(box, projectId, reload) { + box.innerHTML = ` +

Analyse ausschalten

+

Danach werden keine neuen Daten mehr gesammelt und der Banner verschwindet von deiner Website. Die bisherigen Daten bleiben noch 60 Tage erhalten.

+ +
` + const actions = box.querySelector('#an-off-actions') + const error = box.querySelector('#an-off-error') + box.querySelector('#an-off-ask').addEventListener('click', () => { + actions.innerHTML = 'Wirklich ausschalten? ' + actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload)) + actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => { + ev.currentTarget.disabled = true + try { + await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) + reload() + } catch (err) { + renderAnalyseOff(box, projectId, reload) + const e2 = box.querySelector('#an-off-error') + e2.textContent = err.message + e2.classList.remove('hidden') + } + }) + }) +} diff --git a/test/analyseView.test.js b/test/analyseView.test.js new file mode 100644 index 0000000..c1d70c6 --- /dev/null +++ b/test/analyseView.test.js @@ -0,0 +1,59 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { contrastOk } from '../analytics/client/core.js' +import { bannerColor, chartModel, countryName, flagEmoji, niceMax, shortDay, barRows, refLabel } from '../public/analyse-view.js' + +test('contrast und bannerColor stimmen mit core.js ueberein', () => { + for (const c of ['#000000', '#ffffff', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c', '#ffcc00', '#9d3dfe', 'x', '#12']) { + const { contrastOk: portal } = { contrastOk: bannerColor(c) !== '#111111' || c.toLowerCase() === '#111111' } + assert.equal(portal, contrastOk(c) || c.toLowerCase() === '#111111', c) + } + assert.equal(bannerColor('#ffcc00'), '#111111') + assert.equal(bannerColor('#1D4ED8'), '#1d4ed8') + assert.equal(bannerColor('nonsense'), '#111111') +}) + +test('Land und Flagge', () => { + assert.equal(countryName('DE'), 'Deutschland') + assert.equal(countryName(''), 'Unbekannt') + assert.equal(flagEmoji('de'), '🇩🇪') + assert.equal(flagEmoji(''), '') + assert.equal(flagEmoji(''), '') + assert.equal(refLabel(''), 'Direkt') +}) + +test('Diagramm-Skalierung', () => { + assert.equal(niceMax(0), 1) + assert.equal(niceMax(7), 10) + assert.equal(niceMax(130), 200) + assert.equal(shortDay('2026-10-01'), '01.10.') + const series = Array.from({ length: 30 }, (_, i) => ({ day: `2026-09-${String(i + 1).padStart(2, '0')}`, pv: i })) + const m = chartModel(series) + assert.equal(m.points.length, 30) + assert.equal(m.top, 50) + assert.ok(m.points[0].x < m.points[29].x) + assert.ok(m.points.every((p) => p.x >= m.pad.l && p.x <= m.width - m.pad.r && p.y >= m.pad.t && p.y <= m.height - m.pad.b)) + assert.ok(m.xLabels.length <= 7 && m.xLabels.length >= 2) + assert.equal(chartModel([]).points.length, 0) + assert.equal(chartModel([{ day: '2026-10-01', pv: 3 }]).points.length, 1) +}) + +test('Balkenzeilen', () => { + assert.deepEqual(barRows([{ key: 'a', count: 4 }, { key: 'b', count: 2 }], (k) => k.toUpperCase()), [ + { key: 'a', label: 'A', count: 4, pct: 100 }, + { key: 'b', label: 'B', count: 2, pct: 50 }, + ]) + assert.deepEqual(barRows(undefined, String), []) +}) + +test('Portal: Analyse-Ansicht ist verdrahtet', () => { + const app = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8') + const html = readFileSync(new URL('../public/dashboard.html', import.meta.url), 'utf8') + assert.match(app, /analyse: 'analyse'/) + assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/) + assert.match(html, /id="view-analyse"/) + assert.match(html, /id="analyse-body"/) + const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') + assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/) +})