Files
ClaudeandClaude Opus 5.5 0db59d38e9 Analyse: Zeitraum = abgeschlossene Tage, "Heute bisher" getrennt, Feinschliff
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>
2026-10-04 21:34:43 +00:00

354 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
import { esc } from './fn-util.js'
// 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. Mit compare ([{pv}], Zeitraum davor) gemeinsame Skala und gestrichelte Vergleichslinie (nach Tagesindex ausgerichtet). labelGap: Mindestabstand (Einheiten) zwischen der letzten und der vorletzten Beschriftung der x-Achse. */
export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, labelGap = 36, compare } = {}) {
const data = Array.isArray(series) ? series : []
const before = Array.isArray(compare) ? compare : []
const prevOf = (i) => {
const v = before[i]?.pv
return v == null || !Number.isFinite(Number(v)) ? null : Number(v)
}
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), ...data.map((_, i) => prevOf(i) ?? 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, prev: prevOf(i) }))
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 > labelGap).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` : ''
// Vergleichslinie: Luecken (fehlende Tage) beginnen einen neuen Abschnitt
const prevLine = points.reduce((path, p, i) => {
if (p.prev === null) return path
const move = i > 0 && points[i - 1].prev !== null ? 'L' : 'M'
return `${path}${path ? ' ' : ''}${move}${p.x} ${round(y(p.prev))}`
}, '')
return { width, height, pad, top, points, xLabels, yTicks, line, area, prevLine }
}
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) }))
}
/** Banner-Text der Portal-Vorschau; muss mit BANNER_TEXT in analytics/client/core.js uebereinstimmen (Paritaetstest). */
export const BANNER_PREVIEW_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '
/** Zeilen der Verantwortlichen-Vorschau: Firma (sonst Name), Anschrift falls hinterlegt, E-Mail. Leere Angaben entfallen. */
export function controllerLines(controller) {
const c = controller || {}
return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean)
}
/* Analyse-Zustand je Website (comingSoon = Schalter aus) und Auswahl */
export const analyseOf = (project) => project.analyse || { comingSoon: true }
export const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
export function pickAnalyseProject(projects) {
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
}
// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js)
export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }
/* --- Vergleich zum Vorzeitraum und Klartext ("Das Wichtigste"), Spec 2.3 und 2.4 --- */
const MONTHS = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.']
const WEEKDAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] // wie Date#getUTCDay()
const WEEKDAYS_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.']
const AGAINST = 'gegenüber dem Zeitraum davor'
const NUMBER_DE = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 })
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
/** Ganzzahl auf Deutsch: 6930 => "6.930". */
export const fmtNum = (n) => NUMBER_DE.format(Number(n) || 0)
/** Sekunden als m:ss: 108 => "1:48", 7 => "0:07"; null oder ungueltig => "–". */
export function fmtDuration(sec) {
if (!isNum(sec) || sec < 0) return '–'
const s = Math.round(sec)
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}
// "YYYY-MM-DD" => { y, m, d } oder null; Wochentage und Tagesrechnung laufen nur ueber Date.UTC (keine Ortszeit)
function parseDay(day) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day || '')
if (!m) return null
const [y, mo, d] = [+m[1], +m[2], +m[3]]
const t = new Date(Date.UTC(y, mo - 1, d))
return t.getUTCMonth() === mo - 1 && t.getUTCDate() === d ? { y, m: mo, d } : null
}
const weekdayOf = (day) => {
const p = parseDay(day)
return p ? new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() : -1
}
function addDays(day, n) {
const p = parseDay(day)
return p ? new Date(Date.UTC(p.y, p.m - 1, p.d + n)).toISOString().slice(0, 10) : ''
}
/** "2026-09-19" => "Sa., 19.09." */
export function dayLabel(day) {
const w = weekdayOf(day)
return w < 0 ? String(day || '') : `${WEEKDAYS_SHORT[w]}, ${shortDay(day)}`
}
/** ("2026-09-05", "2026-10-04") => "5. Sep. – 4. Okt."; leere oder ungueltige Eingabe => "". */
export function periodLabel(from, to) {
const a = parseDay(from)
const b = parseDay(to)
return a && b ? `${a.d}. ${MONTHS[a.m - 1]} – ${b.d}. ${MONTHS[b.m - 1]}` : ''
}
/** Vergleich moeglich, wenn der Vorzeitraum vollstaendig ist und mindestens 20 Aufrufe hat. Sonst from = "TT.MM." ab dem der Vergleich geht (nur bei Vorzeitraum, der noch nicht voll ist).
Der Zeitraum endet gestern: der Vorzeitraum beginnt heute - 2 x range, also ist er ab firstDay + 2 x range voll. */
export function compareFrom(stats) {
const prev = stats?.previous
if (prev?.complete && Number(prev.totals?.pv) >= 20) return { ok: true, from: '' }
const range = Number(stats?.range)
if (prev && !prev.complete && stats.firstDay && range > 0) {
const from = addDays(stats.firstDay, 2 * range)
return { ok: false, from: from ? shortDay(from) : '' }
}
return { ok: false, from: '' }
}
// Sekunden in Worten fuer Screenreader: 65 => "1 Minute 5 Sekunden"
function secWords(sec) {
const m = Math.floor(sec / 60)
const s = sec % 60
return [m ? `${m} ${m === 1 ? 'Minute' : 'Minuten'}` : '', s || !m ? `${s} ${s === 1 ? 'Sekunde' : 'Sekunden'}` : ''].filter(Boolean).join(' ')
}
/** Veraenderung gegenueber dem Vorwert fuer den Chip: { dir, text, aria } oder null, wenn ein Wert keine Zahl ist. kind 'count': Prozent ab 100/100, sonst absolut; kind 'sec': absolut in Sekunden. */
export function delta(cur, prev, { kind = 'count' } = {}) {
if (!isNum(cur) || !isNum(prev)) return null
const flat = (text) => ({ dir: 'flat', text, aria: `unverändert ${AGAINST}` })
const diff = cur - prev
if (diff === 0) return flat('± 0')
const dir = diff > 0 ? 'up' : 'down'
const arrow = diff > 0 ? '▲' : '▼'
const sign = diff > 0 ? 'plus' : 'minus'
if (kind === 'sec') {
const n = Math.round(Math.abs(diff))
if (n === 0) return flat('± 0')
return { dir, text: `${arrow} ${n < 60 ? `${n} s` : fmtDuration(n)}`, aria: `${sign} ${secWords(n)} ${AGAINST}` }
}
if (cur >= 100 && prev >= 100) {
const pct = (Math.abs(diff) / prev) * 100
if (pct < 1) return flat('± 0 %')
const n = Math.round(pct)
return { dir, text: `${arrow} ${n} %`, aria: `${sign} ${n} Prozent ${AGAINST}` }
}
const n = fmtNum(Math.abs(diff))
return { dir, text: `${arrow} ${n}`, aria: `${sign} ${n} ${AGAINST}` }
}
/** Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; null unter 30 Besuchern (zu unsicher). */
export function contactRate(contacts, visitors) {
if (!isNum(contacts) || !isNum(visitors) || visitors < 30) return null
return Math.round((contacts / visitors) * 1000) / 10
}
// Bekannte Herkunfts-Hosts (Teilstring am Label-Rand: "l.facebook.com" ja, "notgoogle.com" nein)
const REF_NAMES = [
[/(^|\.)google\.(com?\.)?[a-z]{2,3}$/, 'Google'],
[/(^|\.)bing\.com$/, 'Bing'],
[/(^|\.)instagram\.com$/, 'Instagram'],
[/(^|\.)facebook\.com$/, 'Facebook'],
[/(^|\.)duckduckgo\.com$/, 'DuckDuckGo'],
[/(^|\.)ecosia\.org$/, 'Ecosia'],
]
/** Lesbarer Name einer Herkunft; unbekannter Host bleibt unveraendert (beim Einsetzen in HTML maskieren). */
export function refName(host) {
const h = String(host ?? '').trim()
if (!h) return 'Direkt oder unbekannt'
const hit = REF_NAMES.find(([re]) => re.test(h.toLowerCase()))
return hit ? hit[1] : h
}
/** Staerkster Wochentag und Spitzentag ueber die (abgeschlossenen) Tage der Reihe ab firstDay ('' = alle). null bei weniger als 7 Tagen. */
export function weekdayStats(series, firstDay) {
const rows = (Array.isArray(series) ? series : [])
.filter((d) => parseDay(d?.day) && (!firstDay || d.day >= firstDay))
.map((d) => ({ day: d.day, pv: Number(d.pv) || 0 }))
if (rows.length < 7) return null
const avg = rows.reduce((a, r) => a + r.pv, 0) / rows.length
const perWeekday = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 }))
for (const r of rows) {
const w = perWeekday[weekdayOf(r.day)]
w.sum += r.pv
w.n += 1
}
let best = null
perWeekday.forEach((w, weekday) => {
if (w.n && (!best || w.sum / w.n > best.avg)) best = { weekday, name: WEEKDAYS[weekday], avg: w.sum / w.n }
})
if (!best || !(avg > 0) || best.avg < 1.2 * avg) best = null
const max = rows.reduce((m, r) => (r.pv > m.pv ? r : m), rows[0])
const peak = avg > 0 && max.pv >= 1.8 * avg ? { day: max.day, pv: max.pv } : null
return { best, avg, peak }
}
// Direkte Kontaktwege; Klicks auf Links nach außen ('out') sind kein Kontakt zu dir und zählen im Satz nicht mit
const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp' }
const DEVICE_WORDS = { m: 'vom Handy', d: 'vom Computer' }
// Satz ueber dem Hinweis-Block; nur hier steht fett (<b>), alle Zahlen sind fmtNum
function leadOf(stats, cmp) {
const days = Number(stats.range) || (Array.isArray(stats.series) ? stats.series.length : 0)
const pv = Number(stats.totals.pv)
const count = `<b>${fmtNum(pv)}-mal</b>`
if (!cmp.ok) {
return stats.firstDay && stats.from && stats.firstDay >= stats.from
? `Seit dem Start der Analyse am ${esc(shortDay(stats.firstDay))} wurde deine Website ${count} aufgerufen.`
: `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen.`
}
const before = Number(stats.previous.totals.pv)
const diff = pv - before
const start = `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen – `
if (Math.abs(diff) / before < 0.05) return `${start}etwa so oft wie in den ${days} Tagen davor.`
const more = diff > 0
const size = pv >= 100 && before >= 100
? `${fmtNum(Math.round((Math.abs(diff) / before) * 100))} %`
: `${fmtNum(Math.abs(diff))} ${Math.abs(diff) === 1 ? 'Aufruf' : 'Aufrufe'}`
return `${start}<b>${size} ${more ? 'mehr' : 'weniger'}</b> als in den ${days} Tagen davor.`
}
function contactItem(stats, cmp) {
if (!Array.isArray(stats.contacts)) return null // Collector ohne Kontaktliste: nichts behaupten
const direct = (rows) => (Array.isArray(rows) ? rows : []).filter((c) => Object.hasOwn(CONTACT_WAYS, c?.key) && c.count > 0)
const sum = (rows) => rows.reduce((a, c) => a + c.count, 0)
const cur = direct(stats.contacts)
const total = sum(cur)
if (total < 1) {
return Number(stats.totals.visitors) >= 30
? 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.'
: null
}
const main = cur.reduce((m, c) => (!m || c.count > m.count ? c : m), null)
let text = `${fmtNum(total)}-mal hat dich jemand direkt kontaktiert, meist ${CONTACT_WAYS[main.key]} (${fmtNum(main.count)}).`
const before = cmp.ok ? sum(direct(stats.previous.contacts)) : 0
if (before >= 1) {
const diff = total - before
text += diff === 0 ? ' Genauso oft wie zuvor.' : ` Das sind ${fmtNum(Math.abs(diff))} ${diff > 0 ? 'mehr' : 'weniger'} als zuvor.`
}
return text
}
function dayItem(stats) {
if (!(Number(stats.totals.pv) >= 70)) return null
const w = weekdayStats(stats.series, stats.firstDay || '')
if (!w) return null
const parts = []
if (w.best) parts.push(`${w.best.name} ist im Schnitt dein stärkster Tag.`)
if (w.peak) parts.push(`Spitzentag war ${dayLabel(w.peak.day)} mit ${fmtNum(w.peak.pv)} Aufrufen.`)
return parts.length ? parts.join(' ') : null
}
function sourceItem(stats) {
const refs = Array.isArray(stats.refs) ? stats.refs : []
const sum = refs.reduce((a, r) => a + (Number(r.count) || 0), 0)
const first = refs.find((r) => r.key)
if (!first || !(sum > 0) || first.count / sum < 0.2) return null
return `Die meisten, die über einen Link kamen, kommen von ${esc(refName(first.key))} (${fmtNum(first.count)}).`
}
function deviceItem(stats) {
const devices = Array.isArray(stats.devices) ? stats.devices : []
const sum = devices.reduce((a, d) => a + (Number(d.count) || 0), 0)
const main = devices.find((d) => DEVICE_WORDS[d.key] && sum > 0 && d.count / sum >= 0.55)
return main ? `${fmtNum(Math.round((main.count / sum) * 100))} % der Aufrufe kommen ${DEVICE_WORDS[main.key]}.` : null
}
function pageItem(stats) {
const [first, second] = Array.isArray(stats.pages) ? stats.pages : []
if (!first?.key) return null
if (first.key === '/') {
return second?.key
? `Am meisten angesehen: die Startseite, danach ${esc(second.key)} (${fmtNum(second.count)}).`
: `Am meisten angesehen: die Startseite (${fmtNum(first.count)} Aufrufe).`
}
return `Am meisten angesehen: ${esc(first.key)} (${fmtNum(first.count)} Aufrufe).`
}
/** Klartext fuer "Das Wichtigste": { lead, items: [{ icon, html }] } als maskiertes HTML; null unter 30 Seitenaufrufen. Feste Regeln, keine KI. */
export function summarize(stats) {
if (!stats?.totals || !(Number(stats.totals.pv) >= 30)) return null
const cmp = compareFrom(stats)
const items = [
['contact', contactItem(stats, cmp)],
['day', dayItem(stats)],
['source', sourceItem(stats)],
['device', deviceItem(stats)],
['page', pageItem(stats)],
].filter(([, html]) => html).slice(0, 4).map(([icon, html]) => ({ icon, html }))
return { lead: leadOf(stats, cmp), items }
}