diff --git a/public/analyse-view.js b/public/analyse-view.js index 7ea000c..ef3e5b2 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -1,5 +1,7 @@ /* 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 @@ -53,22 +55,33 @@ export function shortDay(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 } = {}) { +/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. Mit compare ([{pv}], Zeitraum davor) gemeinsame Skala und gestrichelte Vergleichslinie (nach Tagesindex ausgerichtet). */ +export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, 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))) + 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 })) + 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 > 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 } + // 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) { @@ -101,3 +114,236 @@ export function pickAnalyseProject(projects) { // 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). */ +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 - 1) + 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 Tage 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 } +} + +const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp', out: 'über einen Link nach außen' } +const DEVICE_WORDS = { m: 'vom Handy', d: 'vom Computer' } + +// Satz ueber dem Hinweis-Block; nur hier steht fett (), 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 = `${fmtNum(pv)}-mal` + 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}${size} ${more ? 'mehr' : 'weniger'} als in den ${days} Tagen davor.` +} + +function contactItem(stats, cmp) { + const total = Number(stats.totals.contacts) || 0 + 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 = (Array.isArray(stats.contacts) ? stats.contacts : []) + .filter((c) => CONTACT_WAYS[c.key] && c.count > 0) + .reduce((m, c) => (!m || c.count > m.count ? c : m), null) + let text = `${fmtNum(total)}-mal hat dich jemand direkt kontaktiert${main ? `, meist ${CONTACT_WAYS[main.key]} (${fmtNum(main.count)})` : ''}.` + const before = cmp.ok ? Number(stats.previous.totals.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 } +} diff --git a/test/analyseInsights.test.js b/test/analyseInsights.test.js new file mode 100644 index 0000000..30c3337 --- /dev/null +++ b/test/analyseInsights.test.js @@ -0,0 +1,258 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { delta, fmtDuration, fmtNum, dayLabel, periodLabel, compareFrom, contactRate, refName, weekdayStats, summarize, chartModel } from '../public/analyse-view.js' + +// Testdaten: eine vollstaendige Statistik-Antwort mit 30 Tagen (Form wie analytics/src/stats.js) +const isoDay = (from, n) => new Date(Date.UTC(+from.slice(0, 4), +from.slice(5, 7) - 1, +from.slice(8, 10) + n)).toISOString().slice(0, 10) +const days = (from, n, pv) => Array.from({ length: n }, (_, i) => ({ day: isoDay(from, i), pv: typeof pv === 'function' ? pv(i, isoDay(from, i)) : pv })) +const t = { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 } +const prev = { + from: '2026-08-06', to: '2026-09-04', complete: true, + totals: { pv: 6080, visitors: 2200, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 }, + series: days('2026-08-06', 30, 203), + contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }, { key: 'wa', count: 23 }], +} +const stats = (over = {}) => ({ + range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01', + totals: t, + series: days('2026-09-05', 30, 231), + pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }], + refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }], + countries: [{ key: 'DE', count: 6000 }], + devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2200 }, { key: 't', count: 140 }], + contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'wa', count: 30 }, { key: 'out', count: 10 }], + banner: { shown: 3950, accept: 2410, reject: 1540 }, + previous: prev, + ...over, +}) + +test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => { + assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent + assert.equal(delta(45, 33).text, '▲ 12') + assert.equal(delta(30, 33).text, '▼ 3') + assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' }) + assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1 + assert.equal(delta(1004, 1000).dir, 'flat') + assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s') + assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05') + assert.equal(delta(null, 3), null) + assert.equal(delta(3, undefined), null) + assert.equal(delta('3', 4), null) +}) + +test('delta: Richtung und aria in Wörtern', () => { + assert.deepEqual(delta(5000, 6000), { dir: 'down', text: '▼ 17 %', aria: 'minus 17 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(45, 33).aria, 'plus 12 gegenüber dem Zeitraum davor') + assert.equal(delta(30, 33).aria, 'minus 3 gegenüber dem Zeitraum davor') + assert.equal(delta(108, 114, { kind: 'sec' }).aria, 'minus 6 Sekunden gegenüber dem Zeitraum davor') + assert.equal(delta(200, 135, { kind: 'sec' }).aria, 'plus 1 Minute 5 Sekunden gegenüber dem Zeitraum davor') + assert.equal(delta(240, 120, { kind: 'sec' }).aria, 'plus 2 Minuten gegenüber dem Zeitraum davor') +}) + +test('fmtDuration, dayLabel, periodLabel, fmtNum', () => { + assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–') + assert.equal(fmtDuration(NaN), '–'); assert.equal(fmtDuration(-3), '–'); assert.equal(fmtDuration(undefined), '–') + assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.') + assert.equal(dayLabel('2026-09-20'), 'So., 20.09.') + assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.') + assert.equal(periodLabel('2026-03-01', '2026-06-30'), '1. März – 30. Juni') + assert.equal(periodLabel('', ''), '') + assert.equal(periodLabel('2026-09-05', ''), '') + assert.equal(fmtNum(6930), '6.930') + assert.equal(fmtNum(0), '0') + assert.equal(fmtNum(1234567), '1.234.567') +}) + +test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => { + assert.deepEqual(compareFrom(stats()), { ok: true, from: '' }) + assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '18.11.' }) // range 30: +59 Tage + assert.deepEqual(compareFrom(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })), { ok: false, from: '11.10.' }) // range 7: +13 Tage + assert.deepEqual(compareFrom(stats({ firstDay: '', previous: { ...prev, complete: false } })), { ok: false, from: '' }) + assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' }) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 20 } } })).ok, true) +}) + +test('contactRate und refName', () => { + assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null) + assert.equal(contactRate(0, 30), 0); assert.equal(contactRate(null, 100), null) + assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example') + assert.equal(refName('google.com'), 'Google'); assert.equal(refName('google.co.uk'), 'Google') + assert.equal(refName('www.bing.com'), 'Bing'); assert.equal(refName('l.instagram.com'), 'Instagram') + assert.equal(refName('m.facebook.com'), 'Facebook'); assert.equal(refName('facebook.com'), 'Facebook') + assert.equal(refName('duckduckgo.com'), 'DuckDuckGo'); assert.equal(refName('www.ecosia.org'), 'Ecosia') + assert.equal(refName('notgoogle.com'), 'notgoogle.com'); assert.equal(refName('google.evil.de'), 'google.evil.de') + assert.equal(refName('Google.DE'), 'Google') +}) + +test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { + // 05.09.2026 ist ein Samstag: 5 Samstage mit 20, alle anderen 10 → Schnitt 11,67; Samstag 20 ≥ 1,2 × 11,67, aber < 1,8 × 11,67 (kein Spitzentag) + const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10) + const w = weekdayStats(days('2026-09-05', 30, sat), '') + assert.equal(w.best.weekday, 6); assert.equal(w.best.name, 'Samstag'); assert.equal(w.best.avg, 20) + assert.ok(Math.abs(w.avg - 350 / 30) < 1e-9) + assert.equal(w.peak, null) + // ein Tag mit dem 3-Fachen des Schnitts → Spitzentag + const spike = (i, day) => (day === '2026-09-19' ? 60 : 10) + const p = weekdayStats(days('2026-09-05', 30, spike), '') + assert.deepEqual(p.peak, { day: '2026-09-19', pv: 60 }) + assert.equal(p.best.name, 'Samstag') + // gleichmäßig: weder stärkster Tag noch Spitzentag + assert.deepEqual(weekdayStats(days('2026-09-05', 30, 10), ''), { best: null, avg: 10, peak: null }) + // weniger als 7 Tage → null; firstDay schneidet Tage davor ab + assert.equal(weekdayStats(days('2026-09-05', 6, 10), ''), null) + assert.notEqual(weekdayStats(days('2026-09-05', 30, 10), '2026-09-28'), null) // 28.09.–04.10. = genau 7 Tage +}) + +test('weekdayStats: firstDay zählt nur Tage ab firstDay', () => { + assert.equal(weekdayStats(days('2026-09-05', 30, 10), '2026-09-29'), null) // nur 6 Tage (29.09.–04.10.) + const w = weekdayStats(days('2026-09-05', 30, (i, day) => (day < '2026-09-20' ? 1000 : 10)), '2026-09-20') + assert.equal(w.avg, 10) // die Tage davor (1000) bleiben außen vor + assert.equal(weekdayStats(null, ''), null) +}) + +test('summarize: Wachstum mit Prozent', () => { + const s = summarize(stats()) + assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – 14 % mehr als in den 30 Tagen davor.') + assert.equal(s.items[0].icon, 'contact') + assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.') + assert.ok(s.items.length <= 4) +}) + +test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { + const lead = (totalsPv, prevPv) => summarize(stats({ totals: { ...t, pv: totalsPv }, previous: { ...prev, totals: { ...prev.totals, pv: prevPv } } })).lead + assert.equal(lead(6930, 7615), 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – 9 % weniger als in den 30 Tagen davor.') + assert.equal(lead(6930, 6800), 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') + assert.equal(lead(80, 68), 'Deine Website wurde in den letzten 30 Tagen 80-mal aufgerufen – 12 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(60, 75), 'Deine Website wurde in den letzten 30 Tagen 60-mal aufgerufen – 15 Aufrufe weniger als in den 30 Tagen davor.') + assert.equal(lead(84, 83), 'Deine Website wurde in den letzten 30 Tagen 84-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') // unter 5 % gilt auch bei kleinen Zahlen + assert.match(lead(32, 31), /aufgerufen – etwa so oft wie in den 30 Tagen davor\.$/) + assert.equal(lead(40, 30), 'Deine Website wurde in den letzten 30 Tagen 40-mal aufgerufen – 10 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(31, 20), 'Deine Website wurde in den letzten 30 Tagen 31-mal aufgerufen – 11 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(80, 80), 'Deine Website wurde in den letzten 30 Tagen 80-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') + assert.equal(lead(7200, 6857), 'Deine Website wurde in den letzten 30 Tagen 7.200-mal aufgerufen – 5 % mehr als in den 30 Tagen davor.') // genau 5 % gilt noch als Veränderung +}) + +test('summarize: ohne Vergleich und neu gestartet', () => { + // previous fehlt: '…in den letzten 30 Tagen X-mal aufgerufen.' + const { previous, ...ohne } = stats() + assert.equal(summarize(ohne).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // Vorzeitraum unvollständig, firstDay vor dem Zeitraum: kein Vergleich, aber kein „Seit dem Start“ + assert.equal(summarize(stats({ firstDay: '2026-08-20', previous: { ...prev, complete: false } })).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // Vorzeitraum zu klein: kein Vergleich + assert.equal(summarize(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 12 } } })).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website X-mal aufgerufen.' + const neu = summarize(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })) + assert.equal(neu.lead, 'Seit dem Start der Analyse am 20.09. wurde deine Website 6.930-mal aufgerufen.') + // auch das Startdatum wird maskiert, falls der Collector je Unsinn liefert + assert.ok(!summarize(stats({ firstDay: 'x', previous: { ...prev, complete: false } })).lead.includes('')) + // ohne Vergleich kein „Das sind … mehr als zuvor“ + assert.equal(neu.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).') + // 7 Tage: „7 Tagen“ + assert.match(summarize(stats({ range: 7 })).lead, /in den letzten 7 Tagen .* als in den 7 Tagen davor\.$/) +}) + +test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => { + assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null) + assert.equal(summarize(null), null) + assert.notEqual(summarize(stats({ totals: { ...t, pv: 30 } })), null) + // contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' + const keine = summarize(stats({ totals: { ...t, contacts: 0, visitors: 40 }, contacts: [] })) + assert.deepEqual(keine.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' }) + const wenig = summarize(stats({ totals: { ...t, contacts: 0, visitors: 29 }, contacts: [] })) + assert.notEqual(wenig.items[0].icon, 'contact') +}) + +test('summarize: Kontakte – Vergleichssatz und häufigste Art', () => { + const html = (contactsNow, contactsBefore, kinds) => summarize(stats({ + totals: { ...t, contacts: contactsNow }, + contacts: kinds, + previous: { ...prev, totals: { ...prev.totals, contacts: contactsBefore } }, + })).items[0].html + assert.equal(html(188, 200, [{ key: 'tel', count: 96 }]), '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 12 weniger als zuvor.') + assert.equal(html(188, 188, [{ key: 'mail', count: 100 }]), '188-mal hat dich jemand direkt kontaktiert, meist per E-Mail (100). Genauso oft wie zuvor.') + assert.equal(html(5, 0, [{ key: 'wa', count: 3 }]), '5-mal hat dich jemand direkt kontaktiert, meist per WhatsApp (3).') // Vorwert 0: kein Vergleichssatz + assert.equal(html(5, 2, [{ key: 'out', count: 3 }]), '5-mal hat dich jemand direkt kontaktiert, meist über einen Link nach außen (3). Das sind 3 mehr als zuvor.') +}) + +test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => { + // refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).' + const s = summarize(stats()) + const byIcon = Object.fromEntries(s.items.map((i) => [i.icon, i.html])) + assert.equal(byIcon.source, 'Die meisten, die über einen Link kamen, kommen von Google (880).') + // devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.' + assert.equal(byIcon.device, '66 % der Aufrufe kommen vom Handy.') + // pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).' + assert.equal(byIcon.page, 'Am meisten angesehen: die Startseite, danach /preise (1.310).') + assert.deepEqual(s.items.map((i) => i.icon), ['contact', 'source', 'device', 'page']) + // Computer, Seite ohne Startseite + const pc = summarize(stats({ devices: [{ key: 'd', count: 600 }, { key: 'm', count: 300 }, { key: 't', count: 100 }], pages: [{ key: '/preise', count: 1310 }, { key: '/', count: 900 }] })) + assert.equal(pc.items.find((i) => i.icon === 'device').html, '60 % der Aufrufe kommen vom Computer.') + assert.equal(pc.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /preise (1.310 Aufrufe).') + // nur Startseite + assert.equal(summarize(stats({ pages: [{ key: '/', count: 50 }] })).items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: die Startseite (50 Aufrufe).') + // Gerät unter 55 %, Herkunft unter 20 %: beide Hinweise entfallen + const flach = summarize(stats({ devices: [{ key: 'm', count: 50 }, { key: 'd', count: 50 }], refs: [{ key: 'google.com', count: 10 }, { key: '', count: 90 }] })) + assert.ok(!flach.items.some((i) => i.icon === 'device' || i.icon === 'source')) + // Direkt (leerer Host) zählt nie als Quelle, auch wenn er vorne steht + const direkt = summarize(stats({ refs: [{ key: '', count: 900 }, { key: 'google.com', count: 300 }] })) + assert.equal(direkt.items.find((i) => i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von Google (300).') + // Samstage 2× → Wochentag-Hinweis; alle 5 Bedingungen wahr → Seite fällt weg + const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10) + const voll = summarize(stats({ series: days('2026-09-05', 30, sat) })) + assert.deepEqual(voll.items.map((i) => i.icon), ['contact', 'day', 'source', 'device']) + assert.equal(voll.items[1].html, 'Samstag ist im Schnitt dein stärkster Tag.') +}) + +test('summarize: Wochentag und Spitzentag in einem Hinweis', () => { + // Samstage 20, Spitzentag Sa., 19.09. mit 371, sonst 10: beides trifft zu + const both = (i, day) => (day === '2026-09-19' ? 371 : new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 40 : 10) + const s = summarize(stats({ series: days('2026-09-05', 30, both) })) + assert.equal(s.items.find((i) => i.icon === 'day').html, 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 19.09. mit 371 Aufrufen.') + // nur Spitzentag am Mittwoch 23.09.: die anderen Mittwoche (0) gleichen den Wochentagsschnitt aus (alle Wochentage Ø 25) + const spike = (i, day) => (day === '2026-09-23' ? 100 : new Date(day + 'T00:00:00Z').getUTCDay() === 3 ? 0 : 25) + const p = summarize(stats({ series: days('2026-09-05', 30, spike) })) + assert.equal(p.items.find((i) => i.icon === 'day').html, 'Spitzentag war Mi., 23.09. mit 100 Aufrufen.') + // weniger als 70 Aufrufe oder weniger als 7 Tage: kein Wochentag-Hinweis + assert.ok(!summarize(stats({ totals: { ...t, pv: 69 }, series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day')) + assert.ok(!summarize(stats({ firstDay: '2026-09-30', series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day')) +}) + +test('summarize: Pfade und Hosts werden maskiert', () => { + const s = summarize(stats({ pages: [{ key: '/', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] })) + assert.ok(!JSON.stringify(s).includes(' i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von a"b.de (900).') + assert.equal(s.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /<img src=x> (900 Aufrufe).') + // auch an zweiter Stelle hinter der Startseite + const z = summarize(stats({ pages: [{ key: '/', count: 900 }, { key: '/