// 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; } export function computeStats(db, site, range, now = Date.now()) { const days = dayList(dayOf(now), range); const cur = collect(db, site, days, now); const { banner, totals, series } = shape(cur); // 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, 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()), }, }; }