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 @@
+
+
+
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 = `Auswertung ansehen `
+ else if (state === 'off') action = `Einrichten `
+ else if (a.reason === 'kein_abo') action = 'Im Wartungs-Abo enthalten. Zu Verträge
'
+ 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 ? `
+
+ Website
+ ${usable.map((p) => `${esc(p.projectName)} `).join('')}
+
` : ''}
+
`
+ 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. Schreib uns im Chat , 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' ? ' Zu Verträge ' : ''}
`
+ else renderAnalyseActivation(stage, project, reload)
+}
+
+function bannerPreview(color) {
+ const c = bannerColor(color)
+ const btn = `style="background:${c};border-color:${c};color:#fff"`
+ return `
+
+
Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: Datenschutz
+
Ablehnen Annehmen
+
`
+}
+
+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('')}
+
+
Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).
+
+
Ich beauftrage WEBklar mit der Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag (Fassung ${esc(a.avvVersion)}). AVV lesen
+
+
Analyse einschalten
+
`
+ 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 ? `` : 'Noch keine Daten.
'}
+ `
+}
+
+const COUNTRY_ROW = (key) => `${flagEmoji(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) => `${r} Tage `).join('')}
+
+
+
+ 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 = `${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}
`
+ 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.
+
+ Seite${pages.map((p) => `${esc(p.key || '/')} `).join('')}
+ Gerät${Object.entries(DEVICE_LABELS).map(([k, v]) => `${v} `).join('')}
+
+
+ Auf meiner Website ansehen
`
+ 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.
+
+ Analyse ausschalten
`
+ 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? Ja, ausschalten Abbrechen '
+ 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\(/)
+})