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', today: { day: '2026-10-05', pv: 42, visitors: 20, contacts: 1 }, 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: '19.11.' }) // range 30: +60 Tage (2 × range, der erste Tag mit vollem Vorzeitraum) assert.deepEqual(compareFrom(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })), { ok: false, from: '12.10.' }) // range 7: +14 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, '178-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 35 mehr als zuvor.') // Telefon + E-Mail + WhatsApp: 96 + 52 + 30 gegen 80 + 40 + 23 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, '178-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, Links nach außen zählen nicht mit', () => { const tel = (n) => ({ key: 'tel', count: n }) const html = (kinds, before) => summarize(stats({ totals: { ...t, contacts: kinds.reduce((a, k) => a + k.count, 0) }, contacts: kinds, previous: { ...prev, contacts: before, totals: { ...prev.totals, contacts: before.reduce((a, k) => a + k.count, 0) } }, })).items[0].html assert.equal(html([tel(96)], [tel(108)]), '96-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 12 weniger als zuvor.') assert.equal(html([{ key: 'mail', count: 100 }], [{ key: 'mail', count: 100 }]), '100-mal hat dich jemand direkt kontaktiert, meist per E-Mail (100). Genauso oft wie zuvor.') assert.equal(html([{ key: 'wa', count: 5 }], []), '5-mal hat dich jemand direkt kontaktiert, meist per WhatsApp (5).') // Vorwert 0: kein Vergleichssatz assert.equal(html([tel(5)], [tel(2)]), '5-mal hat dich jemand direkt kontaktiert, meist per Telefon (5). Das sind 3 mehr als zuvor.') // Links nach außen sind Klicks auf andere Seiten, kein Kontakt: weder in der Zahl noch als häufigste Art, auch nicht im Vorzeitraum assert.equal(html([{ key: 'out', count: 300 }, tel(40)], [{ key: 'out', count: 900 }, tel(40)]), '40-mal hat dich jemand direkt kontaktiert, meist per Telefon (40). Genauso oft wie zuvor.') assert.equal(html([{ key: 'out', count: 300 }, tel(40)], [{ key: 'out', count: 900 }]), '40-mal hat dich jemand direkt kontaktiert, meist per Telefon (40).') // nur Links nach außen (Besucher ≥ 30): wie „keine Kontakte“ const nurOut = summarize(stats({ totals: { ...t, contacts: 300, visitors: 400 }, contacts: [{ key: 'out', count: 300 }] })) assert.deepEqual(nurOut.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' }) // Collector ohne Kontaktliste: kein Satz, der etwas Falsches behauptet const { contacts, ...ohneListe } = stats({ totals: { ...t, contacts: 0, visitors: 400 } }) assert.notEqual(summarize(ohneListe).items[0].icon, 'contact') }) 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: Wochentag-Hinweis zählt alle Tage der Reihe (alle sind abgeschlossen), auch in der 7-Tage-Ansicht', () => { const isSat = (day) => new Date(day + 'T00:00:00Z').getUTCDay() === 6 const dayHtml = (s) => s.items.find((i) => i.icon === 'day')?.html // Reihe 04.09.–03.10.2026 (Ende = gestern, heute steht getrennt in `today`); der letzte Tag, Samstag 03.10., zählt mit const last = '2026-10-03' const base = { from: '2026-09-04', to: last, today: { day: '2026-10-04', pv: 5, visitors: 3, contacts: 0 } } assert.equal(dayHtml(summarize(stats({ ...base, series: days('2026-09-04', 30, (i, day) => (isSat(day) ? 280 : 200)) }))), 'Samstag ist im Schnitt dein stärkster Tag.') // Spitzentag am letzten Tag der Reihe wird erkannt const spike = days('2026-09-04', 30, (i, day) => (day === last ? 400 : 100)) assert.equal(dayHtml(summarize(stats({ ...base, series: spike }))), 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 03.10. mit 400 Aufrufen.') // 7-Tage-Ansicht: genau 7 abgeschlossene Tage reichen für den Hinweis (28.09.–04.10., Sa. 03.10.) const week = days('2026-09-28', 7, (i, day) => (isSat(day) ? 500 : 100)) assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', series: week }))), 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 03.10. mit 500 Aufrufen.') // weniger als 7 Tage (neue Website): weiter kein Hinweis assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', firstDay: '2026-09-29', series: week }))), undefined) // alter Collector ohne `today`: derselbe Hinweis, kein Absturz const { today, ...alt } = stats({ ...base, series: days('2026-09-04', 30, (i, day) => (isSat(day) ? 280 : 200)) }) assert.equal(dayHtml(summarize(alt)), 'Samstag ist im Schnitt dein stärkster Tag.') }) 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: '/