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);