Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9d1691cf6b
commit
69c94d83d7
2 files changed
+509
-5
No files matched your search
+251
-5
@@ -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 (<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) {
|
||||
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 }
|
||||
}
|
||||
@@ -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 <b>6.930-mal</b> aufgerufen – <b>14 % mehr</b> 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 <b>6.930-mal</b> aufgerufen – <b>9 % weniger</b> als in den 30 Tagen davor.')
|
||||
assert.equal(lead(6930, 6800), 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – etwa so oft wie in den 30 Tagen davor.')
|
||||
assert.equal(lead(80, 68), 'Deine Website wurde in den letzten 30 Tagen <b>80-mal</b> aufgerufen – <b>12 Aufrufe mehr</b> als in den 30 Tagen davor.')
|
||||
assert.equal(lead(60, 75), 'Deine Website wurde in den letzten 30 Tagen <b>60-mal</b> aufgerufen – <b>15 Aufrufe weniger</b> als in den 30 Tagen davor.')
|
||||
assert.equal(lead(84, 83), 'Deine Website wurde in den letzten 30 Tagen <b>84-mal</b> 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 <b>40-mal</b> aufgerufen – <b>10 Aufrufe mehr</b> als in den 30 Tagen davor.')
|
||||
assert.equal(lead(31, 20), 'Deine Website wurde in den letzten 30 Tagen <b>31-mal</b> aufgerufen – <b>11 Aufrufe mehr</b> als in den 30 Tagen davor.')
|
||||
assert.equal(lead(80, 80), 'Deine Website wurde in den letzten 30 Tagen <b>80-mal</b> aufgerufen – etwa so oft wie in den 30 Tagen davor.')
|
||||
assert.equal(lead(7200, 6857), 'Deine Website wurde in den letzten 30 Tagen <b>7.200-mal</b> aufgerufen – <b>5 % mehr</b> 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 <b>X-mal</b> aufgerufen.'
|
||||
const { previous, ...ohne } = stats()
|
||||
assert.equal(summarize(ohne).lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> 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 <b>6.930-mal</b> 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 <b>6.930-mal</b> aufgerufen.')
|
||||
// firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>X-mal</b> 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 <b>6.930-mal</b> aufgerufen.')
|
||||
// auch das Startdatum wird maskiert, falls der Collector je Unsinn liefert
|
||||
assert.ok(!summarize(stats({ firstDay: '<i>x</i>', previous: { ...prev, complete: false } })).lead.includes('<i>'))
|
||||
// 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: '/<img src=x>', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] }))
|
||||
assert.ok(!JSON.stringify(s).includes('<img'))
|
||||
assert.ok(JSON.stringify(s).includes('<img'))
|
||||
assert.equal(s.items.find((i) => 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: '/<script>', count: 100 }] }))
|
||||
assert.ok(!JSON.stringify(z).includes('<script>'))
|
||||
})
|
||||
|
||||
test('chartModel mit compare: gemeinsame Skala, prevLine', () => {
|
||||
const cur = days('2026-09-05', 5, 10)
|
||||
const before = days('2026-08-06', 5, (i) => (i === 2 ? 400 : 20))
|
||||
const m = chartModel(cur, { compare: before })
|
||||
assert.equal(m.top, 500) // über beide Reihen (Maximum 400)
|
||||
assert.equal(m.points.length, 5)
|
||||
assert.deepEqual(m.points.map((p) => p.prev), [20, 20, 400, 20, 20])
|
||||
assert.match(m.prevLine, /^M[\d. ]+( L[\d. ]+){4}$/)
|
||||
// nach Tagesindex ausgerichtet: gleiche x-Werte wie die aktuelle Linie, y am Vorwert
|
||||
const first = m.prevLine.split(' L')[0].slice(1).split(' ').map(Number)
|
||||
assert.equal(first[0], m.points[0].x)
|
||||
const mid = m.prevLine.split(' L')[2].split(' ').map(Number)
|
||||
assert.equal(mid[0], m.points[2].x)
|
||||
assert.ok(mid[1] < first[1]) // 400 liegt höher als 20
|
||||
// ohne compare: wie bisher, prevLine leer
|
||||
const o = chartModel(cur)
|
||||
assert.equal(o.top, 10)
|
||||
assert.equal(o.prevLine, '')
|
||||
assert.ok(o.points.every((p) => p.prev === null))
|
||||
assert.equal(o.line, chartModel(cur, { compare: [] }).line)
|
||||
assert.equal(chartModel(cur, { compare: [] }).prevLine, '')
|
||||
// kürzerer Vergleich: nur die vorhandenen Tage, Rest null
|
||||
const k = chartModel(cur, { compare: before.slice(0, 2) })
|
||||
assert.deepEqual(k.points.map((p) => p.prev), [20, 20, null, null, null])
|
||||
assert.match(k.prevLine, /^M[\d. ]+ L[\d. ]+$/)
|
||||
})
|
||||
Reference in new issue
Block a user