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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 21:34:43 +00:00
1 parent eadb768d1a
commit 0db59d38e9
11 files changed
+376 -135

No files matched your search

+7 -1
View File
@@ -97,10 +97,15 @@ function firstDayOf(db, site) {
return fromDaily < fromRaw ? fromDaily : fromRaw;
}
// Der Zeitraum sind die `range` abgeschlossenen Kalendertage, die gestern enden (wie GA4 "Letzte 28 Tage"): Der
// angebrochene heutige Tag würde gegen einen vollen Vorzeitraum als Einbruch erscheinen. Heute steht getrennt in `today`.
export function computeStats(db, site, range, now = Date.now()) {
const days = dayList(dayOf(now), range);
const todayDay = dayOf(now);
const days = dayList(shiftDay(todayDay, -1), range);
const cur = collect(db, site, days, now);
const { banner, totals, series } = shape(cur);
// Heute bis jetzt: live aus den Rohdaten (der Tag ist noch nicht verdichtet)
const t = shape(collect(db, site, [todayDay], now)).series[0];
// Vorzeitraum: die `range` Kalendertage direkt vor dem aktuellen Zeitraum (kalendarisch, nie now - range * 24 h)
const prevDays = dayList(shiftDay(days[0], -1), range);
const prevCollected = collect(db, site, prevDays, now);
@@ -113,6 +118,7 @@ export function computeStats(db, site, range, now = Date.now()) {
firstDay,
totals,
series,
today: { day: todayDay, pv: t.pv, visitors: t.visitors, contacts: t.contacts },
pages: top(cur.dims.pv_path || new Map()),
refs: top(cur.dims.pv_ref || new Map()),
countries: top(cur.dims.pv_country || new Map()),
+81 -36
View File
@@ -115,12 +115,13 @@ async function admin(db, now = () => NOW) {
return { get, close: () => server.close() };
}
test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus daily, consentRate', async () => {
test('Stats-Endpunkt: abgeschlossene Tage bis gestern, heute getrennt in today, Vergangenheit aus daily, consentRate', async () => {
const { db, ev } = mk();
const y = NOW - D;
for (let i = 0; i < 3; i++) ev({ ts: y + i, type: 'pv', path: '/a', vp: 'd', ref: 'g.de', country: 'DE', device: 'd' });
ev({ ts: y + 10, type: 'en', path: '/a', vp: 'd', visitor: 'v1', sec: 5, scroll: 5 });
ev({ ts: y + 11, type: 'bn', kind: 'accept' });
ev({ ts: y + 12, type: 'ct', path: '/a', visitor: 'v1', kind: 'tel' });
runNightly(db, { now: NOW }); // gestern -> daily
ev({ ts: NOW - H, type: 'pv', path: '/b', vp: 'm', country: 'AT', device: 'm' }); // heute live
ev({ ts: NOW - H, type: 'ct', path: '/b', visitor: 'v9', kind: 'mail' });
@@ -132,60 +133,101 @@ test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus dai
const r = await a.get(`/a/admin/stats/${KEY}?range=7`);
assert.equal(r.status, 200);
const j = await r.json();
assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']);
assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'today', 'totals']);
// Der Zeitraum sind die 7 abgeschlossenen Tage bis gestern (3.–9.10.); heute (10.10.) steht nur in `today`
assert.equal(j.series.length, 7);
assert.equal(j.series[0].day, '2026-10-04');
assert.equal(j.series[6].day, '2026-10-10');
assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]);
assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4, engaged: 1, avgSec: 5, avgScroll: 5 });
assert.deepEqual(j.series[5], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 0, avgSec: 5 });
assert.deepEqual(j.series[6], { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1, avgSec: null });
assert.equal(j.from, '2026-10-04'); assert.equal(j.to, '2026-10-10'); assert.equal(j.range, 7);
assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]);
assert.equal(j.series[0].day, '2026-10-03');
assert.equal(j.series[6].day, '2026-10-09');
assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 0, 3]);
assert.deepEqual(j.totals, { pv: 3, visitors: 1, contacts: 1, consentRate: 1, engaged: 1, avgSec: 5, avgScroll: 5 });
assert.deepEqual(j.series[6], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 1, avgSec: 5 });
assert.deepEqual(j.today, { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1 });
assert.equal(j.from, '2026-10-03'); assert.equal(j.to, '2026-10-09'); assert.equal(j.range, 7);
assert.deepEqual(j.pages, [{ key: '/a', count: 3 }]);
assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 });
assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]);
assert.deepEqual(j.devices, [{ key: 'd', count: 3 }, { key: 'm', count: 1 }]);
assert.deepEqual(j.contacts, [{ key: 'mail', count: 1 }]);
assert.deepEqual(j.banner, { shown: 1, accept: 2, reject: 2 });
assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }]);
assert.deepEqual(j.devices, [{ key: 'd', count: 3 }]);
assert.deepEqual(j.contacts, [{ key: 'tel', count: 1 }]);
assert.deepEqual(j.banner, { shown: 0, accept: 1, reject: 0 });
// range 90: 90 Tage
const j90 = await (await a.get(`/a/admin/stats/${KEY}?range=90`)).json();
assert.equal(j90.series.length, 90);
assert.equal(j90.totals.pv, 4);
assert.equal(j90.series[89].day, '2026-10-09');
assert.equal(j90.totals.pv, 3);
assert.deepEqual(j90.today, j.today);
a.close();
});
test('Stats: today = heutiger Berliner Tag bis jetzt, auch ohne Ereignisse; Mitternacht in Berlin trennt gestern und heute', async () => {
const { db, ev } = mk();
const a = await admin(db);
assert.deepEqual((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).today, { day: '2026-10-10', pv: 0, visitors: 0, contacts: 0 });
a.close();
// 11.10. 00:10 Berlin (= 10.10. 22:10 UTC, Sommerzeit): 23:00 Berlin am 10.10. gehört zu gestern, 00:05 Berlin am 11.10. zu heute
const LATE = Date.UTC(2026, 9, 10, 22, 10);
ev({ ts: Date.UTC(2026, 9, 10, 21, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 5), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 6), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 10, 22, 7), type: 'ct', path: '/', visitor: 'v1', kind: 'wa' });
ev({ ts: Date.UTC(2026, 9, 10, 23, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // noch in der Zukunft: zählt nicht
const b = await admin(db, () => LATE);
const j = await (await b.get(`/a/admin/stats/${KEY}?range=7`)).json();
assert.equal(j.to, '2026-10-10');
assert.equal(j.series[6].day, '2026-10-10'); assert.equal(j.series[6].pv, 1);
assert.equal(j.totals.pv, 1);
assert.deepEqual(j.today, { day: '2026-10-11', pv: 2, visitors: 1, contacts: 1 });
assert.equal(j.contacts.length, 0); // die Kontakt-Aktion von 00:07 ist heute
b.close();
});
test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', async () => {
const { db, ev } = mk();
ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // heute: nur in today
const a = await admin(db);
assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1);
const stats = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json());
assert.equal((await stats()).totals.pv, 1);
runNightly(db, { now: NOW });
assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1);
const j = await stats();
assert.equal(j.totals.pv, 1);
assert.equal(j.today.pv, 1);
a.close();
});
test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => {
test('Stats: Vorzeitraum = die range Kalendertage vor dem aktuellen Zeitraum, complete nach firstDay', async () => {
const { db, ev } = mk();
// Vorzeitraum zu range 7 am 10.10.: 27.09.–03.10.
// aktueller Zeitraum zu range 7 am 10.10.: 03.–09.10.; Vorzeitraum: 26.09.–02.10.
ev({ ts: Date.UTC(2026, 8, 30, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 20, scroll: 40 });
ev({ ts: Date.UTC(2026, 9, 2, 10), type: 'ct', path: '/', visitor: 'v2', kind: 'tel' });
const a = await admin(db);
let j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json());
let j = await get();
assert.equal(j.firstDay, '2026-09-30');
assert.equal(j.previous.complete, false);
// 26.09. liegt vor dem Vorzeitraum und darf nicht mitzählen
// 27.09. liegt noch einen Tag nach dem Beginn des Vorzeitraums (26.09.): weiter unvollständig
ev({ ts: Date.UTC(2026, 8, 27, 10), type: 'pv', path: '/x', vp: 'd', country: 'DE', device: 'd' });
j = await get();
assert.equal(j.firstDay, '2026-09-27');
assert.equal(j.previous.complete, false);
// genau am ersten Tag des Vorzeitraums: vollständig
ev({ ts: Date.UTC(2026, 8, 26, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
j = await get();
assert.equal(j.firstDay, '2026-09-26');
assert.equal(j.previous.complete, true);
assert.equal(j.previous.from, '2026-09-27'); assert.equal(j.previous.to, '2026-10-03');
assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[3].day, '2026-09-30');
assert.equal(j.previous.totals.pv, 1);
// 25.09. liegt vor dem Vorzeitraum und darf nicht mitzählen
ev({ ts: Date.UTC(2026, 8, 25, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
j = await get();
assert.equal(j.firstDay, '2026-09-25');
assert.equal(j.previous.complete, true);
assert.equal(j.previous.from, '2026-09-26'); assert.equal(j.previous.to, '2026-10-02');
assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[4].day, '2026-09-30');
assert.equal(j.previous.totals.pv, 3); // 26.09., 27.09., 30.09.
assert.equal(j.previous.totals.engaged, 1);
assert.equal(j.previous.totals.avgSec, 20);
assert.equal(j.previous.totals.contacts, 1);
assert.deepEqual(j.previous.series[4], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 });
assert.deepEqual(j.previous.series[5], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 });
assert.deepEqual(j.previous.series[6], { day: '2026-10-02', pv: 0, visitors: 1, contacts: 1, avgSec: null });
assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]);
assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer
assert.equal(j.totals.avgSec, null);
@@ -223,8 +265,8 @@ test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async (
assert.equal(j.totals.engaged, 2);
assert.equal(j.totals.avgSec, 61); // 60,5 gerundet
assert.equal(j.totals.avgScroll, 75);
assert.equal(j.series[5].avgSec, 61);
assert.equal(j.series[4].avgSec, null);
assert.equal(j.series[6].avgSec, 61); // 9.10. ist der letzte abgeschlossene Tag
assert.equal(j.series[5].avgSec, null);
// ohne en: beide null, engaged 0
const leer = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(leer.previous.totals.engaged, 0);
@@ -238,20 +280,23 @@ test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappu
// schon der 12.10. (00:30 Berlin) und ein naives Fenster würde sich überlappen; mittags fiele das nicht auf.
const NOV = Date.UTC(2026, 10, 10, 22, 30);
const { db, ev } = mk();
ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: Vorzeitraum
ev({ ts: Date.UTC(2026, 9, 12, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 12.10. mittags: aktueller Zeitraum
ev({ ts: Date.UTC(2026, 9, 10, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.10. mittags: Vorzeitraum
ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: aktueller Zeitraum
ev({ ts: Date.UTC(2026, 10, 10, 12), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.11. mittags: heute
const a = await admin(db, () => NOV);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(j.series[0].day, '2026-10-12');
assert.equal(j.series[29].day, '2026-11-10');
assert.equal(j.previous.series[29].day, '2026-10-11');
assert.equal(j.previous.series[0].day, '2026-09-12');
assert.equal(j.previous.from, '2026-09-12'); assert.equal(j.previous.to, '2026-10-11');
assert.equal(j.series[0].day, '2026-10-11');
assert.equal(j.series[29].day, '2026-11-09');
assert.equal(j.previous.series[29].day, '2026-10-10');
assert.equal(j.previous.series[0].day, '2026-09-11');
assert.equal(j.from, '2026-10-11'); assert.equal(j.to, '2026-11-09');
assert.equal(j.previous.from, '2026-09-11'); assert.equal(j.previous.to, '2026-10-10');
const all = [...j.series, ...j.previous.series].map((s) => s.day);
assert.equal(all.length, 60);
assert.equal(new Set(all).size, 60);
assert.equal(j.previous.totals.pv, 1); assert.equal(j.previous.series[29].pv, 1);
assert.equal(j.totals.pv, 1); assert.equal(j.series[0].pv, 1);
assert.equal(j.today.day, '2026-11-10'); assert.equal(j.today.pv, 1);
a.close();
});
@@ -274,7 +319,7 @@ test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => {
test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => {
const { db, ev } = mk();
for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' });
for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - D - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' });
const a = await admin(db);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(j.pages.length, 20);
+46 -21
View File
@@ -2,7 +2,7 @@
Jeder Wert aus Daten (Pfade, Hosts, Projektnamen, Tage, Links) laeuft durch esc(). Texte: Spec 2.1-2.4.
Vertrag fuer analyse.js und analyse.css (Praefix an-, Zustaende als Klasse "on" oder Attribut):
Kopf .an-top > .an-site[data-state=on|warn|off] (.an-status-dot, .an-site-name | select#an-project.an-pick, .an-status) + .an-seg[role=group] button[data-range=7|30|90][aria-pressed] (.on)
Kopf .an-top > .an-site[data-state=on|warn|off] (.an-status-dot, .an-site-name | select#an-project.an-pick, .an-status "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr") + .an-seg[role=group] button[data-range=7|30|90][aria-pressed] (.on)
Reiter .an-tabs[role=tablist] > button.an-tabbtn[role=tab][data-tab=uebersicht|heatmap|einstellungen][aria-selected] (.on), aria-controls="an-stage"
Wurzel .an-ov (Raster, Abstand 14 px) enthaelt: .an-sum, .an-kpis, .an-row2, 2x .an-pair, .an-credit
Wichtigste .an-sum > h3.an-eyebrow, p.an-lead (darin <b>), ul.an-points > li > span.an-point-ico (svg.an-ico) + span; Leerzustand: .an-sum.an-sum-empty
@@ -50,19 +50,24 @@ const rateDe = (n) => n.toLocaleString('de-DE', { minimumFractionDigits: 1, maxi
const STATE_TEXT = { disabled: 'Analyse ist abgeschaltet', unknown: 'Status gerade nicht abrufbar' }
// Statuszeile: aktiv (mit Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand
function statusOf(project, devMode, stamp) {
// Statuszeile: aktiv (mit "Heute bisher" aus stats.today und Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand
function statusOf(project, devMode, stamp, today) {
if (devMode) return { s: 'warn', text: 'Im Dev-Modus wird nicht gemessen.' }
const a = project?.analyse
const state = typeof a?.state === 'string' ? a.state : 'active'
if (state === 'active') return { s: 'on', text: `Analyse aktiv${stamp ? ` · Stand: heute, ${stamp} Uhr` : ''}` }
if (state === 'active') {
const pv = isNum(today?.pv) ? today.pv : null // alter Collector ohne today: kein "Heute bisher"
// jeder Teil bricht nur als Ganzes in die naechste Zeile (kein "Stand:" allein am Zeilenende)
const parts = ['Analyse aktiv', pv === null ? '' : `Heute bisher: ${fmtNum(pv)} ${pv === 1 ? 'Aufruf' : 'Aufrufe'}`, stamp ? `Stand: ${stamp} Uhr` : '']
return { s: 'on', text: parts.filter(Boolean).map((t) => `<span class="an-st">${t}</span>`).join(' · ') }
}
return { s: 'off', text: Object.hasOwn(STATE_TEXT, state) ? STATE_TEXT[state] : a.eligible === false ? 'Analyse nicht verfügbar' : 'Analyse ist noch ausgeschaltet' }
}
/** Kopfleiste. usable: Websites mit Analyse (Auswahlfeld erst ab 2), stamp: Uhrzeit "19:42" des Ladens. */
export function headerHtml({ project, usable, range, showRange, devMode, stamp } = {}) {
/** Kopfleiste. usable: Websites mit Analyse (Auswahlfeld erst ab 2), stamp: Uhrzeit "19:42" des Ladens, today: stats.today (heute bisher). */
export function headerHtml({ project, usable, range, showRange, devMode, stamp, today } = {}) {
const sites = list(usable)
const st = statusOf(project, devMode, stamp ? esc(stamp) : '')
const st = statusOf(project, devMode, stamp ? esc(stamp) : '', today)
const current = RANGES.includes(Number(range)) ? Number(range) : 30
const name = sites.length > 1
? `<select id="an-project" class="an-pick" aria-label="Website wählen">${sites.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project?.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>`
@@ -92,19 +97,33 @@ export function summaryHtml(stats) {
return `<section class="an-sum">${head}<p class="an-lead">${s.lead}</p>${points}</section>`
}
/** Mini-Verlauf (Flaeche + Linie, ohne Achsen) aus Zahlen; unter 2 Werten nichts. */
/** Mini-Verlauf (Flaeche + Linie, ohne Achsen) aus Zahlen; null/undefined ist eine Luecke (Tag ohne Messung): die Linie bricht dort ab, es wird nie als 0 gezeichnet. Unter 2 Werten nichts. */
export function sparkHtml(values) {
const v = list(values).map(num)
if (v.length < 2) return ''
const max = Math.max(1, ...v) * 1.1
const path = v.map((y, i) => `${i ? 'L' : 'M'}${round((200 * i) / (v.length - 1))} ${round(36 - (32 * y) / max)}`).join(' ')
return `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${path} L200 38 L0 38 Z"/><path class="an-spark-line" d="${path}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
const v = list(values).map((x) => (x === null || x === undefined ? null : num(x)))
if (v.length < 2 || v.filter((x) => x !== null).length < 2) return ''
const max = Math.max(1, ...v.filter((x) => x !== null)) * 1.1
const x = (i) => round((200 * i) / (v.length - 1))
const pt = (y, i) => `${x(i)} ${round(36 - (32 * y) / max)}`
// zusammenhaengende Abschnitte ohne Luecke: [[wert, index], ...]
const runs = []
v.forEach((y, i) => {
if (y === null) runs.push(null)
else if (runs.length && runs[runs.length - 1]) runs[runs.length - 1].push([y, i])
else runs.push([[y, i]])
})
const parts = runs.filter(Boolean)
const seg = (r) => r.map(([y, i], k) => `${k ? 'L' : 'M'}${pt(y, i)}`).join(' ')
// ein einzelner Tag zwischen Luecken wird ein Punkt (Strich der Laenge 0, runde Linienenden)
const line = parts.map((r) => (r.length === 1 ? `${seg(r)} L${pt(...r[0])}` : seg(r))).join(' ')
const area = parts.filter((r) => r.length > 1).map((r) => `${seg(r)} L${x(r[r.length - 1][1])} 38 L${x(r[0][1])} 38 Z`).join(' ')
return `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${area}"/><path class="an-spark-line" d="${line}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
}
// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null
const trend = (series, key) => {
// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null.
// gaps: Tage ohne Wert bleiben null (Luecke im Verlauf), z.B. die Zeit pro Seite an Tagen ohne Zustimmung
const trend = (series, key, gaps = false) => {
const rows = list(series)
return rows.some((d) => Object.hasOwn(d ?? {}, key)) ? rows.map((d) => num(d?.[key])) : null
return rows.some((d) => Object.hasOwn(d ?? {}, key)) ? rows.map((d) => (gaps && !isNum(d?.[key]) ? null : num(d?.[key]))) : null
}
export function kpisHtml(stats) {
@@ -120,7 +139,7 @@ export function kpisHtml(stats) {
const field = { pv: 'pv', visitors: 'visitors', contacts: 'contacts', time: 'avgSec' }
const tile = (k) => {
const foot = k.d ? `${chip(k.d)}<span class="an-vs">vs. ${esc(k.vs)}</span>` : !cmp.ok && cmp.from ? `<span class="an-vs">Vergleich ab ${esc(cmp.from)}</span>` : ''
return `<div class="an-card an-kpi" data-kpi="${k.key}"><span class="an-k">${k.label}${k.star ? '<span class="an-star">*</span>' : ''}</span><b class="an-v">${esc(k.value)}</b>${k.sub ? `<small class="an-sub">${esc(k.sub)}</small>` : ''}<div class="an-cmp">${foot}</div>${sparkHtml(trend(stats?.series, field[k.key]))}</div>`
return `<div class="an-card an-kpi" data-kpi="${k.key}"><span class="an-k">${k.label}${k.star ? '<span class="an-star">*</span>' : ''}</span><b class="an-v">${esc(k.value)}</b>${k.sub ? `<small class="an-sub">${esc(k.sub)}</small>` : ''}<div class="an-cmp">${foot}</div>${sparkHtml(trend(stats?.series, field[k.key], k.key === 'time'))}</div>`
}
return `<div class="an-kpis">${tiles.map(tile).join('')}</div>`
}
@@ -139,7 +158,11 @@ export function chartHtml(stats) {
const cmp = compareFrom(stats)
const head = (legend) => `<div class="an-head"><h3>Seitenaufrufe pro Tag</h3>${legend}</div>`
if (!series.length) return `<section class="an-card an-chartbox">${head('')}<p class="an-empty">Noch keine Daten.</p></section>`
const m = chartModel(series, { height: 300, pad: { l: 40, r: 24, t: 12, b: 28 }, compare: cmp.ok ? stats.previous.series : undefined })
// Schrift der Achsen in viewBox-Einheiten: bis ca. 24 auf dem schmalsten Handy (analyse.css). Der linke Rand haelt die breiteste
// y-Beschriftung (Ziffern ca. 0,6 x 24 breit), die x-Beschriftungen halten 100 Einheiten Abstand (eine Datumsbeschriftung ist bei Schrift 28 ca. 92 breit)
const opts = { height: 300, maxLabels: 5, labelGap: 100, compare: cmp.ok ? stats.previous.series : undefined }
const padL = Math.max(40, Math.round(6 + String(chartModel(series, opts).top).length * 14.4))
const m = chartModel(series, { ...opts, pad: { l: padL, r: 24, t: 12, b: 40 } })
const hasPrev = Boolean(m.prevLine)
const days = Number(stats?.range) || series.length
const total = m.points.reduce((sum, p) => sum + p.pv, 0)
@@ -147,8 +170,8 @@ export function chartHtml(stats) {
const legend = `<div class="an-legend"><span class="an-leg"><i class="an-leg-line"></i>${days} Tage</span>${hasPrev ? '<span class="an-leg"><i class="an-leg-line an-leg-prev"></i>Zeitraum davor</span>' : ''}</div>`
const dots = m.points.map((p, i) => `<circle class="an-dot" data-i="${i}" cx="${p.x}" cy="${p.y}" r="3" data-day="${esc(p.day)}" data-pv="${p.pv}"${p.prev === null ? '' : ` data-prev="${p.prev}"`}/>`).join('')
return `<section class="an-card an-chartbox">${head(legend)}<div class="an-chartwrap"><svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title"><title id="an-chart-title">${esc(title)}</title>`
+ m.yTicks.map((k) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${k.y}" y2="${k.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${k.y + 4}" text-anchor="end">${esc(k.label)}</text>`).join('')
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')
+ m.yTicks.map((k) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${k.y}" y2="${k.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${k.y}" dy="0.35em" text-anchor="end">${esc(k.label)}</text>`).join('')
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 9}" text-anchor="middle">${esc(l.label)}</text>`).join('')
+ `<path class="an-area" d="${m.area}"/>${hasPrev ? `<path class="an-prev" d="${m.prevLine}"/>` : ''}<path class="an-line" d="${m.line}"/>${dots}</svg><div class="an-tip" hidden></div></div></section>`
}
@@ -196,7 +219,9 @@ export function rankCardHtml(card, stats, { tab, expanded } = {}) {
const def = CARDS[card]
const active = def.tabs.some(([k]) => k === tab) ? tab : def.tabs[0][0]
const items = list(stats?.[active])
const sum = items.reduce((a, r) => a + num(r?.count), 0)
const listSum = items.reduce((a, r) => a + num(r?.count), 0)
// Anteil an allen Seitenaufrufen (die Liste zeigt hoechstens 20 Eintraege); Herkunft kennt nur die Summe der Liste (Besucher ohne Zustimmung haben keine)
const sum = active === 'refs' ? listSum : Math.max(listSum, num(stats?.totals?.pv))
const share = (c) => {
const pct = sum > 0 ? (c / sum) * 100 : 0
return pct > 0 && pct < 0.5 ? '&lt; 1 %' : `${Math.round(pct)} %`
+18 -21
View File
@@ -55,8 +55,8 @@ export function shortDay(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). */
export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, compare } = {}) {
/** 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) => {
@@ -70,7 +70,7 @@ export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r
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 > 36).map((p) => ({ x: p.x, label: shortDay(p.day) }))
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)
@@ -165,13 +165,14 @@ export function periodLabel(from, 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). */
/** 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 - 1)
const from = addDays(stats.firstDay, 2 * range)
return { ok: false, from: from ? shortDay(from) : '' }
}
return { ok: false, from: '' }
@@ -232,7 +233,7 @@ export function refName(host) {
return hit ? hit[1] : h
}
/** Staerkster Wochentag und Spitzentag ueber die Tage ab firstDay ('' = alle). null bei weniger als 7 Tagen. */
/** 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))
@@ -255,7 +256,8 @@ export function weekdayStats(series, firstDay) {
return { best, avg, peak }
}
const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp', out: 'über einen Link nach außen' }
// 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
@@ -280,17 +282,19 @@ function leadOf(stats, cmp) {
}
function contactItem(stats, cmp) {
const total = Number(stats.totals.contacts) || 0
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 = (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
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.`
@@ -298,16 +302,9 @@ function contactItem(stats, cmp) {
return text
}
// Der letzte Tag der Reihe ist heute und noch nicht zu Ende: er zaehlt beim Wochentag-Hinweis nicht mit
function completedDays(stats) {
const series = Array.isArray(stats.series) ? stats.series : []
const last = series[series.length - 1]
return last && (!stats.to || last.day === stats.to) ? series.slice(0, -1) : series
}
function dayItem(stats) {
if (!(Number(stats.totals.pv) >= 70)) return null
const w = weekdayStats(completedDays(stats), stats.firstDay || '')
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.`)
+20 -4
View File
@@ -5,7 +5,7 @@
--an-fg: #fff;
--an-fg2: rgba(255, 255, 255, 0.78);
--an-muted: rgba(255, 255, 255, 0.58);
--an-faint: rgba(255, 255, 255, 0.4);
--an-faint: rgba(255, 255, 255, 0.55); /* >= 4,5:1 auf der Karte (gemessen ca. 6,2:1) */
--an-line: rgba(255, 255, 255, 0.09);
--an-line2: rgba(255, 255, 255, 0.18);
--an-card: rgba(14, 16, 20, 0.92);
@@ -38,6 +38,7 @@
.an-status-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--an-dot); box-shadow: 0 0 0 4px var(--an-dot-soft); }
.an-site-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.an-status { display: block; margin-top: 2px; font-size: 12px; color: var(--an-muted); }
.an-st { white-space: nowrap; }
.an-pick {
display: block; max-width: 100%; padding: 2px 24px 2px 0; border: 0; border-radius: 6px; cursor: pointer; text-overflow: ellipsis;
-webkit-appearance: none; appearance: none; color: var(--an-fg); font-family: inherit; font-size: 16px; font-weight: 700; line-height: 1.3;
@@ -107,10 +108,21 @@
.an-leg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.an-leg-line { display: inline-block; width: 18px; height: 0; border-top: 3px solid var(--an-acc); border-radius: 2px; }
.an-leg-prev { border-top: 2px dashed var(--an-prev); }
.an-chartwrap { position: relative; flex: 1 1 auto; display: flex; align-items: center; min-height: 0; }
.an-chartwrap { position: relative; flex: 1 1 auto; display: flex; align-items: center; min-height: 0; container-type: inline-size; }
.an-chart { display: block; width: 100%; height: auto; max-height: 100%; overflow: visible; touch-action: pan-y; }
.an-grid { stroke: var(--an-line); stroke-width: 1px; vector-effect: non-scaling-stroke; }
/* Achsenschrift in viewBox-Einheiten (viewBox 640 breit, die Zeichnung skaliert mit der Kartenbreite). Je nach Breite der Zeichnung
so gewaehlt, dass sie immer ca. 11-12 px gross erscheint (gemessen: 320 px Handy bis 1920 px Desktop); Grundwert fuer Browser ohne Container-Abfragen. */
.an-axis { fill: var(--an-muted); font-size: 13px; }
@container (max-width: 279px) { .an-axis { font-size: 28px; } }
@container (min-width: 280px) and (max-width: 329px) { .an-axis { font-size: 24px; } }
@container (min-width: 330px) and (max-width: 379px) { .an-axis { font-size: 21px; } }
@container (min-width: 380px) and (max-width: 439px) { .an-axis { font-size: 18px; } }
@container (min-width: 440px) and (max-width: 499px) { .an-axis { font-size: 16px; } }
@container (min-width: 500px) and (max-width: 559px) { .an-axis { font-size: 14px; } }
@container (min-width: 560px) and (max-width: 629px) { .an-axis { font-size: 12.5px; } }
@container (min-width: 630px) and (max-width: 699px) { .an-axis { font-size: 11px; } }
@container (min-width: 700px) { .an-axis { font-size: 10px; } }
.an-area { fill: var(--an-acc); opacity: 0.12; stroke: none; }
.an-prev { fill: none; stroke: var(--an-prev); stroke-width: 2px; stroke-dasharray: 5 5; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.an-line { fill: none; stroke: var(--an-acc); stroke-width: 2.4px; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
@@ -196,6 +208,7 @@
.an-ghost:hover { background: rgba(255, 255, 255, 0.07); }
.an-danger { color: #fca5a5; }
.an .error { margin: 0 0 12px; text-align: left; }
#an-off p.error { color: #fca5a5; }
/* ------------------------------------------- Einschalten und andere Zustaende --- */
.an-state p, .an-panel p { margin: 0 0 12px; line-height: 1.55; color: var(--an-fg2); }
@@ -258,8 +271,12 @@
@media (max-width: 880px) {
.an-top { padding: 14px; }
.an-seg { display: flex; width: 100%; }
.an-seg button { flex: 1 1 0; padding: 9px 8px; }
.an-seg button { flex: 1 1 0; padding: 9px 8px; min-height: 40px; }
.an-tabs { gap: 20px; margin: 14px 2px; }
/* Tippflaechen mindestens 40 px hoch */
.an-tabbtn { min-height: 40px; padding-top: 8px; }
.an-ltab, .an-more, .an-btn { min-height: 40px; }
.an-ltab { display: inline-flex; align-items: center; padding: 6px 14px; }
.an-card { padding: 16px; }
.an-kpi { padding: 14px 14px 10px; }
.an-v { font-size: 26px; }
@@ -267,7 +284,6 @@
.an-sum { padding: 18px 16px; }
.an-lead { font-size: 19px; }
.an-sum-empty .an-lead { font-size: 16px; }
.an-axis { font-size: 17px; }
.an-heat-bar label { max-width: none; }
.an-bn { display: block; margin: 12px 0; padding: 16px; }
.an-bn-row { flex-direction: column; margin-top: 12px; }
+4 -4
View File
@@ -48,10 +48,10 @@ export async function initAnalyse(root, { projectId } = {}) {
else renderAnalyseActivation(stage, project, reload)
}
// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens
function headHtml(project, usable, { showRange, stamp = '' } = {}) {
// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens, today = "Heute bisher" aus der Statistik
function headHtml(project, usable, { showRange, stamp = '', today } = {}) {
const a = analyseOf(project)
return headerHtml({ project, usable, range: RANGES.includes(analyseSel.range) ? analyseSel.range : 30, showRange, devMode: a.state === 'active' && Boolean(a.devMode), stamp })
return headerHtml({ project, usable, range: RANGES.includes(analyseSel.range) ? analyseSel.range : 30, showRange, devMode: a.state === 'active' && Boolean(a.devMode), stamp, today })
}
function bannerPreview(color) {
@@ -182,7 +182,7 @@ function mountCockpit({ an, stage, project, usable, reload }) {
const bar = an.querySelector('.an-top')
if (!bar) return
const focused = bar.contains(document.activeElement) ? document.activeElement.dataset.range : ''
bar.outerHTML = headHtml(project, usable, { showRange: tabNow() === 'uebersicht', stamp: state.stamp })
bar.outerHTML = headHtml(project, usable, { showRange: tabNow() === 'uebersicht', stamp: state.stamp, today: state.stats?.today })
if (focused) an.querySelector(`.an-seg [data-range="${focused}"]`)?.focus()
}
// Reiterknoepfe in place umschalten (Fokus bleibt), nur der gewaehlte ist per Tab erreichbar, die anderen per Pfeiltaste
+78 -12
View File
@@ -16,6 +16,7 @@ const prev = {
}
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: rich(days('2026-09-05', 30, (i) => (i === 14 ? 371 : 231))),
pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }],
@@ -76,7 +77,7 @@ test('summaryHtml: Satz und Hinweise mit Symbol; Leerzustand unter 30 Aufrufen',
assert.match(h, /Deine Website wurde in den letzten 30 Tagen <b>6\.930-mal<\/b> aufgerufen/)
assert.equal(count(h, /<li>/g), 4); assert.match(h, /<svg class="an-ico"/)
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // alle Symbole der Hinweise sind belegt
assert.match(h, /188-mal hat dich jemand direkt kontaktiert, meist per Telefon \(96\)\./)
assert.match(h, /178-mal hat dich jemand direkt kontaktiert, meist per Telefon \(96\)\./) // ohne die 10 Links nach außen
const few = summaryHtml(stats({ totals: { ...t, pv: 12 } }))
assert.match(few, /Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen\./)
assert.doesNotMatch(few, /<ul/)
@@ -92,9 +93,17 @@ test('kpisHtml: Chips, Vorwerte, Mini-Verläufe, Zeit mit Scrolltiefe', () => {
assert.match(h, /aria-label="plus 14 Prozent gegenüber dem Zeitraum davor"/)
assert.match(h, /Besucher<span class="an-star">\*<\/span>/)
assert.equal(count(h, /<svg class="an-spark"/g), 4)
// Verlauf der Zeit: fehlende Tageswerte (null) zählen als 0
// Verlauf der Zeit: Tage ohne Messung (null) fehlen im Verlauf, sie werden nicht als 0 gezeichnet
const noTime = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, avgSec: i === 0 ? null : 100 })) }))
assert.equal(count(noTime, /<svg class="an-spark"/g), 4)
const timeLine = (html) => /data-kpi="time".*?class="an-spark-line" d="([^"]*)"/s.exec(html)[1]
assert.match(timeLine(noTime), /^M6\.9 /) // der erste Tag (x = 0) fehlt, die Linie beginnt am zweiten
assert.equal(count(timeLine(noTime), /M/g), 1)
assert.doesNotMatch(timeLine(noTime), /M0 |L0 /)
assert.match(timeLine(kpisHtml(full)), /^M0 /) // vollständige Reihe: Linie ab dem ersten Tag
// Zählwerte behalten 0: ein Tag ohne Aufrufe ist ein echter Tiefpunkt
const zeroPv = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, pv: i === 0 ? 0 : 231 })) }))
assert.match(/data-kpi="pv".*?class="an-spark-line" d="([^"]*)"/s.exec(zeroPv)[1], /^M0 36 /)
// Reihe ohne avgSec-Feld: kein Verlauf bei der Zeit
assert.equal(count(kpisHtml(stats({ series: full.series.map(({ avgSec, ...d }) => d) })), /<svg class="an-spark"/g), 3)
// keine Zeitmessung: Strich statt "0:00"
@@ -102,12 +111,12 @@ test('kpisHtml: Chips, Vorwerte, Mini-Verläufe, Zeit mit Scrolltiefe', () => {
assert.match(none, /<b class="an-v">–<\/b>/); assert.doesNotMatch(none, /gescrollt|vs\. –/)
})
test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => {
test('kpisHtml: neue Website zeigt "Vergleich ab 19.11."', () => {
const h = kpisHtml(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } }))
assert.equal(count(h, /Vergleich ab 18\.11\./g), 4)
assert.equal(count(h, /Vergleich ab 19\.11\./g), 4)
assert.doesNotMatch(h, /an-delta|vs\./)
const k7 = kpisHtml(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } }))
assert.match(k7, /Vergleich ab 11\.10\./)
assert.match(k7, /Vergleich ab 12\.10\./)
// vollständiger Vorzeitraum, aber unter 20 Aufrufen: weder Chip noch Datum
const tiny = kpisHtml(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 5 } } }))
assert.doesNotMatch(tiny, /an-delta|vs\.|Vergleich ab/)
@@ -129,6 +138,22 @@ test('sparkHtml: Fläche und Linie, nichts bei weniger als 2 Werten, kein NaN',
assert.doesNotMatch(sparkHtml([3, null, undefined, 'x']), /NaN|undefined|null/)
})
test('sparkHtml: Lücken (null) unterbrechen die Linie statt als 0 zu gelten', () => {
const line = (h) => /class="an-spark-line" d="([^"]*)"/.exec(h)[1]
const area = (h) => /class="an-spark-area" d="([^"]*)"/.exec(h)[1]
// Wert 0 liegt auf der Grundlinie (y = 36); eine Lücke darf dort keinen Punkt erzeugen
const gap = sparkHtml([4, 6, null, 6, 8])
assert.equal(count(line(gap), /M/g), 2) // zwei Abschnitte: Tage 0-1 und 3-4
assert.doesNotMatch(line(gap), / 36( |$)/)
assert.equal(count(area(gap), /Z/g), 2) // Fläche je Abschnitt
assert.doesNotMatch(line(gap), /(^|[ML])100 /) // der Tag mit Lücke (x = 100) hat keinen Punkt
assert.match(line(sparkHtml([null, 5, 7, 9])), /^M66\.7 /) // Lücke am Anfang: Linie beginnt später, nicht bei 0
assert.equal(sparkHtml([null, null, null]), ''); assert.equal(sparkHtml([null, 4]), '') // unter 2 Messwerten nichts
assert.doesNotMatch(sparkHtml([4, null, 6]), /NaN|undefined|null/)
// alle Werte vorhanden: ein Abschnitt wie bisher
assert.equal(count(line(sparkHtml([1, 5, 3, 8])), /M/g), 1)
})
test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte mit Daten', () => {
const h = chartHtml(full)
assert.match(h, /<title id="an-chart-title">Seitenaufrufe pro Tag vom 05\.09\. bis 04\.10\., insgesamt 7\.\d{3}<\/title>/)
@@ -146,6 +171,27 @@ test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte
assert.doesNotMatch(chartHtml(zero), /NaN|Infinity|undefined|null/)
})
test('chartHtml: Achsenbeschriftung hat Platz – y-Rand wächst mit der Stellenzahl, x-Beschriftungen überlappen nicht', () => {
const padOf = (html) => Number(/class="an-grid" x1="([\d.]+)"/.exec(html)[1])
const series = (pv, n = 30) => days('2026-09-05', n, pv).map((d) => ({ ...d, visitors: 1, contacts: 0, avgSec: 1 }))
const none = { previous: { ...prev, complete: false } }
// Skala 500 (3 Zeichen), 5.000 (4), 20.000 (5), 50 (2): Platz für die Ziffern der y-Beschriftung
assert.equal(padOf(chartHtml(stats({ series: series(300), ...none }))), 49)
assert.equal(padOf(chartHtml(stats({ series: series(2400), ...none }))), 64)
assert.equal(padOf(chartHtml(stats({ series: series(12000), ...none }))), 78)
assert.equal(padOf(chartHtml(stats({ series: series(30), ...none }))), 40)
// y-Beschriftung steht rechtsbündig 6 Einheiten vor dem Raster und ist an der Gitterlinie zentriert
const h = chartHtml(stats({ series: series(2400), ...none }))
assert.match(h, /<text class="an-axis" x="58" y="[\d.]+" dy="0\.35em" text-anchor="end">5000<\/text>/)
// x-Beschriftungen: nie näher als 100 Einheiten (Platz für "29.09." in der größten Schrift), letzter Tag bleibt
for (const n of [7, 30, 90]) {
const xs = [...chartHtml(stats({ series: series(300, n), ...none })).matchAll(/class="an-axis" x="([\d.]+)" y="[\d.]+" text-anchor="middle"/g)].map((m) => Number(m[1]))
assert.ok(xs.length >= 3, `range ${n}`)
assert.ok(xs.every((x, i) => !i || x - xs[i - 1] >= 100), `range ${n}: ${xs}`)
assert.equal(xs[xs.length - 1], 616) // 640 - rechter Rand 24: der letzte Tag
}
})
test('chartTipText: Blase mit und ohne Vorwert', () => {
assert.equal(chartTipText('2026-09-19', 371, 210), 'Sa., 19.09.: 371 Aufrufe (davor 210)')
assert.equal(chartTipText('2026-09-19', '371', '210'), 'Sa., 19.09.: 371 Aufrufe (davor 210)') // Werte aus data-Attributen sind Strings
@@ -196,7 +242,7 @@ test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20,
assert.match(h, /&lt;img src=x&gt;/); assert.doesNotMatch(h, /<img src=x>/)
const pages = rankCardHtml('seen', full)
assert.match(pages, /Startseite/); assert.doesNotMatch(pages, />\/</)
assert.match(pages, /<b>3\.200<\/b><em>59 %<\/em>/) // 3200 von 5410
assert.match(pages, /<b>3\.200<\/b><em>46 %<\/em>/) // 3200 von 6930 Aufrufen (die Liste zeigt nur 5410 davon)
assert.match(pages, /data-rank="seen" data-rank-active="pages"/)
assert.match(pages, /<h3>Was angesehen wird<\/h3>/)
assert.match(pages, /data-rank-tab="pages" aria-pressed="true"/); assert.match(pages, /data-rank-tab="refs" aria-pressed="false"/)
@@ -228,6 +274,17 @@ test('rankCardHtml: Geräte, Länder mit Flagge, ungültiger Reiter, leere Liste
const tiny = rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 10000 }, { key: '/b', count: 1 }] }))
assert.match(tiny, /<em>&lt; 1 %<\/em>/)
assert.doesNotMatch(rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 0 }] })), /NaN|Infinity/)
// Nenner: Seiten, Länder, Geräte relativ zu max(Summe der Liste, Seitenaufrufe); Herkunft nur zur Summe der Liste
const part = stats({ totals: { ...t, pv: 1000 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }], countries: [{ key: 'DE', count: 250 }], devices: [{ key: 'm', count: 500 }], refs: [{ key: 'google.com', count: 30 }, { key: '', count: 10 }] })
assert.match(rankCardHtml('seen', part), /<b>300<\/b><em>30 %<\/em>/) // 300 von 1000, nicht von 400 (75 %)
assert.match(rankCardHtml('who', part, { tab: 'countries' }), /<b>250<\/b><em>25 %<\/em>/)
assert.match(rankCardHtml('who', part), /<b>500<\/b><em>50 %<\/em>/)
assert.match(rankCardHtml('seen', part, { tab: 'refs' }), /<b>30<\/b><em>75 %<\/em>/) // Herkunft: 30 von 40
// Liste größer als die Gesamtzahl (Zählungen laufen auseinander): die Summe, nie mehr als 100 %
const over = stats({ totals: { ...t, pv: 100 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }] })
assert.match(rankCardHtml('seen', over), /<b>300<\/b><em>75 %<\/em>/)
// ohne totals (alter Collector): Summe der Liste
assert.match(rankCardHtml('seen', { pages: [{ key: '/a', count: 3 }, { key: '/b', count: 1 }] }), /<em>75 %<\/em>/)
// Länder-Schlüssel maskiert
const bad = rankCardHtml('who', stats({ countries: [{ key: '<b>', count: 3 }] }), { tab: 'countries' })
assert.match(bad, /Unbekannt/); assert.doesNotMatch(bad, /<b><|<b>&/); assert.doesNotMatch(bad, /an-flag/)
@@ -247,26 +304,35 @@ test('basisHtml: Ring, Sätze mit und ohne Entscheidungen', () => {
test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => {
const a = project()
const b = { projectId: 'p2', projectName: 'Café "Zum <Bären>"' }
const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42' })
const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42', today: { day: '2026-10-05', pv: 1234, visitors: 80, contacts: 3 } })
assert.doesNotMatch(one, /an-project|<select/); assert.match(one, /<b class="an-site-name">Boulderhalle Überhang<\/b>/)
assert.match(one, /Analyse aktiv · Stand: heute, 19:42 Uhr/)
assert.match(one, /<small class="an-status"><span class="an-st">Analyse aktiv<\/span> · <span class="an-st">Heute bisher: 1\.234 Aufrufe<\/span> · <span class="an-st">Stand: 19:42 Uhr<\/span><\/small>/)
assert.match(one, /<button type="button" class="on" data-range="30" aria-pressed="true">30 Tage<\/button>/)
assert.equal(count(one, /data-range="/g), 3); assert.match(one, /data-range="7"[^>]*aria-pressed="false"/)
const two = headerHtml({ project: b, usable: [a, b], range: 7, showRange: false, devMode: false, stamp: '' })
assert.match(two, /<select id="an-project"/); assert.match(two, /<option value="p2" selected>Café &quot;Zum &lt;Bären&gt;&quot;<\/option>/)
assert.match(two, /<option value="p1">Boulderhalle Überhang<\/option>/)
assert.doesNotMatch(two, /data-range/); assert.doesNotMatch(two, /Zum <Bären>/); assert.doesNotMatch(two, /Stand:/)
assert.match(two, /Analyse aktiv/)
assert.match(two, /Analyse aktiv/); assert.doesNotMatch(two, /Heute bisher/) // ohne today (alter Collector) entfällt der Teil
assert.match(headerHtml({ project: a, usable: [a], range: 365, showRange: true }), /class="on" data-range="30"/) // unbekannter Zeitraum => 30
// Dev-Modus
const dev = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: true, stamp: '19:42' })
assert.match(dev, /Im Dev-Modus wird nicht gemessen\./); assert.doesNotMatch(dev, /Analyse aktiv|Stand:/); assert.match(dev, /data-state="warn"/)
assert.match(dev, /Im Dev-Modus wird nicht gemessen\./); assert.doesNotMatch(dev, /Analyse aktiv|Stand:|Heute bisher/); assert.match(dev, /data-state="warn"/)
assert.match(one, /data-state="on"/)
// Name maskiert
const evil = headerHtml({ project: { projectId: 'x', projectName: '<img src=x onerror=alert(1)>' }, usable: [], range: 30, showRange: false })
assert.doesNotMatch(evil, /<img/); assert.match(evil, /&lt;img src=x onerror=alert\(1\)&gt;/)
const odd = headerHtml({ project: { projectId: 'p"1', projectName: 'A' }, usable: [{ projectId: 'p"1', projectName: 'A' }, { projectId: 'p2', projectName: 'B' }], stamp: '<b>x</b>' })
assert.match(odd, /value="p&quot;1" selected/); assert.match(odd, /heute, &lt;b&gt;x&lt;\/b&gt; Uhr/); assert.doesNotMatch(odd, /<b>x/)
const odd = headerHtml({ project: { projectId: 'p"1', projectName: 'A' }, usable: [{ projectId: 'p"1', projectName: 'A' }, { projectId: 'p2', projectName: 'B' }], stamp: '<b>x</b>', today: { pv: '<i>5</i>' } })
assert.match(odd, /value="p&quot;1" selected/); assert.match(odd, /Stand: &lt;b&gt;x&lt;\/b&gt; Uhr/); assert.doesNotMatch(odd, /<b>x|<i>/)
// Heute bisher: Einzahl, 0 und nur mit Zahl; ohne Uhrzeit nur der Zähler
const today = (pv, stamp = '') => headerHtml({ project: a, usable: [a], range: 30, showRange: false, stamp, today: { day: '2026-10-05', pv, visitors: 0, contacts: 0 } })
const plain = (h) => /<small class="an-status">(.*?)<\/small>/.exec(h)[1].replace(/<[^>]+>/g, '') // Text ohne die Umbruch-Spans
assert.equal(plain(today(1, '08:05')), 'Analyse aktiv · Heute bisher: 1 Aufruf · Stand: 08:05 Uhr')
assert.match(today(0, '08:05'), /Heute bisher: 0 Aufrufe/)
assert.equal(plain(today(42)), 'Analyse aktiv · Heute bisher: 42 Aufrufe')
assert.doesNotMatch(headerHtml({ project: a, usable: [a], today: {} }), /Heute bisher|NaN/) // today ohne pv: nichts behaupten
// Zustände ohne Messung zeigen kein "Heute bisher"
assert.doesNotMatch(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [], today: { pv: 5 } }), /Heute bisher/)
// Zustände ohne Messung sagen nicht "aktiv"
const off = headerHtml({ project: project({ analyse: { state: 'off', eligible: true } }), usable: [], range: 30, showRange: false })
assert.doesNotMatch(off, /Analyse aktiv/); assert.match(off, /data-state="off"/)
+38 -33
View File
@@ -14,6 +14,7 @@ const prev = {
}
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 }],
@@ -66,8 +67,8 @@ test('fmtDuration, dayLabel, periodLabel, fmtNum', () => {
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: '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)
@@ -116,7 +117,7 @@ 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.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)
})
@@ -148,7 +149,7 @@ test('summarize: ohne Vergleich und neu gestartet', () => {
// 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).')
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\.$/)
})
@@ -164,16 +165,26 @@ test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 B
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 },
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, totals: { ...prev.totals, contacts: contactsBefore } },
previous: { ...prev, contacts: before, totals: { ...prev.totals, contacts: before.reduce((a, k) => a + k.count, 0) } },
})).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.')
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', () => {
@@ -219,30 +230,24 @@ test('summarize: Wochentag und Spitzentag in einem Hinweis', () => {
assert.ok(!summarize(stats({ firstDay: '2026-09-30', series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day'))
})
test('summarize: der heutige, unvollständige Tag zählt beim Wochentag-Hinweis nicht mit', () => {
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, heute = Samstag 03.10. mit erst 5 Aufrufen; Samstage sonst 280, alle anderen Tage 200
const today = '2026-10-03'
const sat = (i, day) => (day === today ? 5 : isSat(day) ? 280 : 200)
const series = days('2026-09-04', 30, sat)
const base = { from: '2026-09-04', to: today, series }
// zählte der Tag mit, läge der Samstag-Schnitt (225) unter 1,2 × Gesamtschnitt (204) → kein Hinweis
assert.equal(weekdayStats(series, '').best, null)
// ohne den heutigen Tag: Samstag Ø 280 ≥ 1,2 × 211, stärkster Tag; Spitzentag nein (280 < 1,8 × 211)
assert.equal(dayHtml(summarize(stats(base))), 'Samstag ist im Schnitt dein stärkster Tag.')
// Spitzentag: Mi., 16.09. mit 365 (zählte heute mit, wäre 365 ≥ 1,8 × 199 ein Spitzentag; ohne heute 365 < 1,8 × 205,7)
const wed = days('2026-09-04', 30, (i, day) => (day === today ? 5 : day === '2026-09-16' ? 365 : 200))
assert.ok(weekdayStats(wed, '').peak)
assert.equal(dayHtml(summarize(stats({ ...base, series: wed }))), undefined)
// fehlt `to`, entfällt der letzte Tag trotzdem
assert.equal(dayHtml(summarize(stats({ ...base, to: undefined }))), 'Samstag ist im Schnitt dein stärkster Tag.')
// endet die Reihe nicht auf `to`, ist der letzte Tag ein abgeschlossener Tag und zählt mit
assert.equal(dayHtml(summarize(stats({ ...base, to: '2026-10-04' }))), undefined)
// 7-Tage-Ansicht: nur 6 abgeschlossene Tage → kein Wochentag-Hinweis, auch bei deutlichem Muster
// 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 }))), undefined)
assert.ok(weekdayStats(week, '')) // mit allen 7 Tagen wäre es ein Ergebnis gewesen
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', () => {
+7
View File
@@ -37,6 +37,13 @@ test('Diagramm-Skalierung', () => {
assert.ok(m.xLabels.length <= 7 && m.xLabels.length >= 2)
assert.equal(chartModel([]).points.length, 0)
assert.equal(chartModel([{ day: '2026-10-01', pv: 3 }]).points.length, 1)
// labelGap: die vorletzte Beschriftung rückt weg, wenn sie der letzten zu nahe kommt (30 Tage: 29.09. ist 79 Einheiten vor 03.10.)
const labels = (opts) => chartModel(series, { width: 640, ...opts }).xLabels.map((l) => l.label)
assert.equal(labels({}).length, 7)
const wide = chartModel(series, { labelGap: 90 }).xLabels
assert.equal(wide.length, 6)
assert.ok(wide.every((l, i, a) => !i || l.x - a[i - 1].x >= 90))
assert.equal(wide[wide.length - 1].label, '30.09.') // letzter Tag bleibt
})
test('Balkenzeilen', () => {
+30
View File
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict'
import { createAnalyticsClient } from '../server/services/analyticsClient.js'
import { createAdminServer, createPublicServer } from '../analytics/src/server.js'
import { openDb } from '../analytics/src/db.js'
import { computeStats } from '../analytics/src/stats.js'
import { compareFrom, shortDay } from '../public/analyse-view.js'
const SITE = 'abcdef0123456789abcd'
const DOMAIN = 'kunde.example.com'
@@ -39,9 +41,18 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as
await post({ t: 'ct', path: '/', kind: 'tel' })
await post({ t: 'bn', a: 'accept' })
// Frisch gemessen: heute steht nur in `today`, der Zeitraum sind die abgeschlossenen Tage bis gestern
const jetzt = await client.getStats(SITE, '7')
assert.deepEqual(jetzt.today, { day: jetzt.today.day, pv: 3, visitors: 1, contacts: 1 })
assert.equal(jetzt.totals.pv, 0)
assert.ok(jetzt.today.day > jetzt.to && jetzt.series.every((d) => d.day <= jetzt.to))
// Um 36 Stunden zurückdatieren: liegt immer auf einem abgeschlossenen Tag des 7-Tage-Zeitraums
db.prepare('UPDATE events_raw SET ts = ts - 36 * 3600 * 1000 WHERE site = ?').run(SITE)
const stats = await client.getStats(SITE, '7')
assert.equal(stats.totals.pv, 3)
assert.equal(stats.totals.contacts, 1)
assert.equal(stats.today.pv, 0)
assert.equal(stats.series.length, 7)
assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3)
assert.equal(stats.range, 7)
@@ -76,3 +87,22 @@ test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledi
assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince)
assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true })
})
test('Vertrag: "Vergleich ab" des Portals ist der erste Tag, an dem der Collector den Vorzeitraum als vollständig meldet', () => {
const firstDay = '2026-09-20'
const evDb = openDb(':memory:')
evDb.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(SITE, 'p1', '["a.de"]')
evDb.prepare("INSERT INTO events_raw (site, ts, type, path, vp, country, device) VALUES (?,?,'pv','/','d','DE','d')").run(SITE, Date.UTC(2026, 8, 20, 10))
for (const range of [7, 30]) {
const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10)
const at = (day) => computeStats(evDb, SITE, range, Date.parse(`${day}T10:00:00Z`))
const first = addDays(firstDay, 2 * range) // "Vergleich ab" laut Portal
const dayBefore = at(addDays(first, -1))
assert.equal(dayBefore.previous.complete, false, `range ${range}: am Tag davor noch nicht vollständig`)
assert.deepEqual(compareFrom(dayBefore), { ok: false, from: shortDay(first) })
const onDay = at(first)
assert.equal(onDay.previous.complete, true, `range ${range}: am Tag selbst vollständig`)
assert.equal(onDay.previous.from, firstDay)
assert.equal(compareFrom(onDay).from, '') // kein Datum mehr (der Vorzeitraum ist voll; ok bleibt false, denn es gibt nur 1 Aufruf)
}
})
+47 -3
View File
@@ -47,11 +47,13 @@ const BEFORE = {
}
const SCALE = { 7: 0.25, 30: 1, 90: 3 } // Anteil der 30-Tage-Zahlen je Zeitraum
const PAGES = [['/', 2480], ['/preise', 1310], ['/kurse', 1150], ['/routen', 960], ['/oeffnungszeiten', 640], ['/team', 410], ['/kontakt', 300], ['/impressum', 120], ['/datenschutz', 90]]
// Der Zeitraum endet gestern (abgeschlossene Tage); heute steht getrennt in `today`
function makeStats(range, over = {}) {
const from = addDays(TODAY, -(range - 1))
const from = addDays(TODAY, -range)
const pfrom = addDays(from, -range)
return {
range, from, to: TODAY, firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)),
range, from, to: addDays(TODAY, -1), firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)),
today: { day: TODAY, pv: 42, visitors: 17, contacts: 2 },
pages: PAGES.map(([key, count]) => ({ key, count: Math.round(count * SCALE[range]) })),
refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }].map((r) => ({ ...r, count: Math.round(r.count * SCALE[range]) })),
countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: 'CH', count: 330 }],
@@ -79,6 +81,8 @@ const SCENARIOS = {
wenig: { project: project(), stats: (r) => makeStats(r, { totals: { ...TOTALS[r], pv: 12 } }) },
dev: { project: project({ devMode: true }), stats: (r) => makeStats(r) },
alt: { project: project(), stats: (r) => OLD(r) },
stark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 2400, Math.min(14, r - 1)) }) },
sehrStark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 12000, Math.min(14, r - 1)) }) },
langerPfad: { project: project(), stats: (r) => makeStats(r, { pages: [{ key: longPath, count: 900 }, { key: '/kurz', count: 500 }], refs: [{ key: `${'h'.repeat(90)}.example.com`, count: 300 }] }) },
fehler: { project: project(), stats: null },
zwei: { projects: [project(), { ...baseProject, projectId: 'p2', projectName: 'Zweite Website mit einem wirklich sehr langen Namen, der nicht in eine Zeile passt', analyse: analyse({}) }], stats: (r) => makeStats(r) },
@@ -168,7 +172,7 @@ try {
await open('full')
check(L('aktiv: Block "Das Wichtigste" mit "14 % mehr"'), (await text('.an-sum')).includes('Das Wichtigste') && (await text('.an-sum')).includes('14 % mehr'))
check(L('aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼'), (await count('.an-kpis .an-kpi')) === 4 && (await count('.an-kpis .an-delta.up')) === 3 && (await count('.an-kpis .an-delta.down')) === 1 && (await text('.an-kpis')).includes('▲') && (await text('.an-kpis')).includes('▼'))
check(L('aktiv: Kopfleiste mit Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /Analyse aktiv · Stand: heute, \d\d:\d\d Uhr/.test(await text('.an-status')) && (await page.getAttribute('.an-seg .on', 'data-range')) === '30' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht')
check(L('aktiv: Kopfleiste mit "Heute bisher: 42 Aufrufe" und Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /^Analyse aktiv · Heute bisher: 42 Aufrufe · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')) && (await page.getAttribute('.an-seg .on', 'data-range')) === '30' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht')
check(L('kein_horizontaler_scroll aktiv'), await noScroll())
// Raster: Desktop 4 Kacheln nebeneinander, Handy 2 x 2; Diagramm und Kontaktkarte gleich hoch (Desktop)
const tiles = await page.$$eval('.an-kpis .an-kpi', (els) => els.map((e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width) } }))
@@ -208,6 +212,28 @@ try {
}
await page.waitForFunction(() => document.querySelector('.an-tip')?.hidden === true)
check(L('Diagramm-Blase: verschwindet wieder, kein Punkt bleibt markiert'), (await count('.an-dot.on')) === 0)
// Achsenbeschriftung: gerendert ca. 11-12 px (viewBox 640 skaliert mit der Breite), nichts ragt aus der Karte, x-Beschriftungen überlappen nicht
const axis = () => page.evaluate(() => {
const svg = document.querySelector('.an-chart')
const scale = svg.getBoundingClientRect().width / 640
const box = document.querySelector('.an-chartbox').getBoundingClientRect()
const texts = [...svg.querySelectorAll('.an-axis')].map((t) => ({ r: t.getBoundingClientRect(), px: parseFloat(getComputedStyle(t).fontSize) * scale, anchor: t.getAttribute('text-anchor'), s: t.textContent }))
const xs = texts.filter((t) => t.anchor === 'middle').sort((a, b) => a.r.left - b.r.left)
return {
px: Math.min(...texts.map((t) => t.px)), pxMax: Math.max(...texts.map((t) => t.px)),
inside: texts.every((t) => t.r.left >= box.left + 1 && t.r.right <= box.right - 1),
gap: xs.every((t, i) => !i || t.r.left >= xs[i - 1].r.right + 4),
y: texts.filter((t) => t.anchor === 'end').map((t) => t.s).join(','),
}
})
const ax = await axis()
check(L(`Achsenbeschriftung ca. 11-12 px (${ax.px.toFixed(1)} px), liegt in der Karte, x-Werte überlappen nicht`), ax.px >= 10.5 && ax.pxMax <= 13 && ax.inside && ax.gap)
const faint = await page.evaluate(() => getComputedStyle(document.querySelector('.an-credit')).color)
check(L(`Hilfstext "--an-faint" kontrastreich (${faint})`), Number(/rgba?\([^)]*?,\s*([\d.]+)\)$/.exec(faint)?.[1] ?? 1) >= 0.55)
if (touch) {
const heights = await page.evaluate(() => Object.fromEntries(['.an-tabbtn', '.an-seg button', '.an-ltab', '.an-more', '.an-teaser .an-btn'].map((sel) => [sel, Math.min(...[...document.querySelectorAll(sel)].map((e) => e.getBoundingClientRect().height))])))
check(L(`Tippflächen mindestens 40 px: ${Object.entries(heights).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(', ')}`), Object.values(heights).every((h) => h >= 39.5))
}
// Ranglisten: Umschalter und "Alle anzeigen" ändern nur ihre Karte
check(L('Rangliste: 6 Zeilen und "Alle anzeigen"'), (await count('[data-rank="seen"] .an-lr')) === 6 && (await count('[data-rank="seen"] [data-rank-more]')) === 1)
await page.click('[data-rank="seen"] [data-rank-more]')
@@ -255,6 +281,16 @@ try {
const settings = await text('#analyse-body')
check(L('Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"'), (await page.isVisible('#an-off-ask')) && settings.includes('Analyse ausschalten') && /aktiv seit 12\.08\.2026/.test(settings) && settings.includes('Fassung 2026-10-02') && (await count('.an-docs a')) === 2 && (await count('.an-swatch')) === 1 && (await count('.an-top .an-seg')) === 0)
check(L('kein_horizontaler_scroll einstellungen'), await noScroll())
const errColor = await page.evaluate(() => {
const p = document.createElement('p')
p.className = 'error'
p.textContent = 'x'
document.querySelector('#an-off').appendChild(p)
const c = getComputedStyle(p).color
p.remove()
return c
})
check(L(`Fehlertext beim Ausschalten rot (${errColor})`), errColor === 'rgb(252, 165, 165)')
await shot('einstellungen')
await page.click('#an-off-ask')
await page.click('#an-off-yes')
@@ -311,6 +347,7 @@ try {
await shot('dev')
await open('alt')
check(L('alt: kein "Heute bisher" (alter Collector), Stand bleibt'), /^Analyse aktiv · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')))
check(L('alt: keine Chips, keine pageerror'), (await count('.an-delta')) === 0 && (await count('.an-prev')) === 0 && errors.length === 0 && (await text('.an-kpis')).includes('612'))
await shot('alt')
@@ -323,6 +360,13 @@ try {
check(L('fehler: "Neu laden" fragt erneut an'), statsRequests.length === requestsBefore + 1)
await shot('fehler')
for (const [sc, label5] of [['stark', 'vierstellige'], ['sehrStark', 'fünfstellige']]) {
await open(sc)
const big = await axis()
check(L(`${label5} y-Werte (${big.y}) nicht abgeschnitten, x-Werte überlappen nicht, Schrift ${big.px.toFixed(1)} px`), big.inside && big.gap && big.px >= 10.5 && big.pxMax <= 13)
await shot(sc === 'stark' ? 'skala-4-stellig' : 'skala-5-stellig')
}
await open('langerPfad')
const name = await page.evaluate(() => { const n = document.querySelector('[data-rank="seen"] .an-name'); return { sw: n.scrollWidth, cw: n.clientWidth, title: n.title } })
check(L(`langer Pfad${touch ? ', 402 px' : ''}: document.scrollingElement.scrollWidth <= innerWidth`), (await noScroll()) && name.sw > name.cw && name.title.length > 100)