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>
138 lines
6.0 KiB
JavaScript
138 lines
6.0 KiB
JavaScript
// Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten.
|
|
import { dayOf, aggregateRows } from './jobs.js';
|
|
|
|
const TOP = 20;
|
|
|
|
// Kalendertag (YYYY-MM-DD) um `n` Tage verschoben, rein kalendarisch (UTC-Datumsrechnung, keine Zeitumstellung).
|
|
function shiftDay(day, n) {
|
|
const [y, m, d] = day.split('-').map(Number);
|
|
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
|
|
}
|
|
|
|
// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Tag `endDay`.
|
|
function dayList(endDay, range) {
|
|
const days = [];
|
|
for (let i = range - 1; i >= 0; i--) days.push(shiftDay(endDay, -i));
|
|
return days;
|
|
}
|
|
|
|
const top = (map) => [...map].map(([key, count]) => ({ key, count })).filter((e) => e.count > 0)
|
|
.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1)).slice(0, TOP);
|
|
|
|
// Sammelt die Zähler für eine zusammenhängende Tagesliste: verdichtete Tage aus `daily`,
|
|
// Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten. Kein Tag zählt doppelt.
|
|
function collect(db, site, days, now) {
|
|
const first = days[0];
|
|
const last = days[days.length - 1];
|
|
const inRange = new Set(days);
|
|
const perDay = new Map(days.map((d) => [d, { pv: 0, visitors: 0, contacts: 0, en: 0, sec: 0, scroll: 0 }]));
|
|
const dims = {};
|
|
const apply = (day, dim, key, n) => {
|
|
const t = perDay.get(day);
|
|
if (dim === 'pv_total') t.pv += n;
|
|
else if (dim === 'visitors') t.visitors += n;
|
|
else if (dim === 'ct_kind') t.contacts += n;
|
|
else if (dim === 'en_count') t.en += n;
|
|
else if (dim === 'en_sec_sum') t.sec += n;
|
|
else if (dim === 'scroll_sum') t.scroll += n;
|
|
if (!dims[dim]) dims[dim] = new Map();
|
|
dims[dim].set(key, (dims[dim].get(key) || 0) + n);
|
|
};
|
|
|
|
const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last).map((r) => r.day));
|
|
for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last)) {
|
|
if (inRange.has(r.day)) apply(r.day, r.dim, r.key, r.count);
|
|
}
|
|
// Ein Berliner Tag beginnt 22:00/23:00 UTC des Vortags: zwei Tage Rand reichen auf beiden Seiten, danach filtert dayOf
|
|
const [fy, fm, fd] = first.split('-').map(Number);
|
|
const [ly, lm, ld] = last.split('-').map(Number);
|
|
const lo = Date.UTC(fy, fm - 1, fd - 2);
|
|
const hi = Math.min(now, Date.UTC(ly, lm - 1, ld + 2));
|
|
const live = new Map();
|
|
for (const r of db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts')
|
|
.all(site, lo, hi)) {
|
|
const d = dayOf(r.ts);
|
|
if (!inRange.has(d) || done.has(d)) continue;
|
|
if (!live.has(d)) live.set(d, []);
|
|
live.get(d).push(r);
|
|
}
|
|
for (const [day, rows] of live) for (const [dim, key, n] of aggregateRows(rows)) apply(day, dim, key, n);
|
|
return { days, perDay, dims };
|
|
}
|
|
|
|
const avg = (sum, n) => (n ? Math.round(sum / n) : null);
|
|
|
|
// Formt gesammelte Zähler zu banner + totals + series (gleiche Form für aktuellen Zeitraum und Vorzeitraum).
|
|
function shape({ days, perDay, dims }) {
|
|
const sum = (dim) => [...(dims[dim] || new Map()).values()].reduce((a, b) => a + b, 0);
|
|
const banner = { shown: sum('bn_shown'), accept: sum('bn_accept'), reject: sum('bn_reject') };
|
|
const decided = banner.accept + banner.reject;
|
|
const en = sum('en_count');
|
|
return {
|
|
banner,
|
|
totals: {
|
|
pv: sum('pv_total'),
|
|
// Summe der Tages-Besucher (kein tagesübergreifender Abgleich möglich: Salz wechselt täglich)
|
|
visitors: sum('visitors'),
|
|
contacts: sum('ct_kind'),
|
|
consentRate: decided ? banner.accept / decided : 0,
|
|
engaged: en,
|
|
avgSec: avg(sum('en_sec_sum'), en),
|
|
avgScroll: avg(sum('scroll_sum'), en),
|
|
},
|
|
series: days.map((day) => {
|
|
const t = perDay.get(day);
|
|
return { day, pv: t.pv, visitors: t.visitors, contacts: t.contacts, avgSec: avg(t.sec, t.en) };
|
|
}),
|
|
};
|
|
}
|
|
|
|
// Frühester Berliner Tag mit Daten dieser Website ('' = keine): kleinster Tag aus daily und events_raw.
|
|
function firstDayOf(db, site) {
|
|
const fromDaily = db.prepare('SELECT MIN(day) AS d FROM daily WHERE site = ?').get(site)?.d || '';
|
|
const ts = db.prepare('SELECT MIN(ts) AS t FROM events_raw WHERE site = ?').get(site)?.t;
|
|
const fromRaw = ts == null ? '' : dayOf(ts);
|
|
if (!fromDaily) return fromRaw;
|
|
if (!fromRaw) return fromDaily;
|
|
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 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);
|
|
const prev = shape(prevCollected);
|
|
const firstDay = firstDayOf(db, site);
|
|
return {
|
|
range,
|
|
from: days[0],
|
|
to: days[days.length - 1],
|
|
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()),
|
|
devices: top(cur.dims.pv_device || new Map()),
|
|
contacts: top(cur.dims.ct_kind || new Map()),
|
|
banner,
|
|
previous: {
|
|
from: prevDays[0],
|
|
to: prevDays[prevDays.length - 1],
|
|
complete: firstDay !== '' && firstDay <= prevDays[0],
|
|
totals: prev.totals,
|
|
series: prev.series,
|
|
contacts: top(prevCollected.dims.ct_kind || new Map()),
|
|
},
|
|
};
|
|
}
|