diff --git a/analytics/src/stats.js b/analytics/src/stats.js index e2033a9..f065080 100644 --- a/analytics/src/stats.js +++ b/analytics/src/stats.js @@ -1,69 +1,137 @@ // Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten. import { dayOf, aggregateRows } from './jobs.js'; -const DAY_MS = 86400 * 1000; const TOP = 20; -// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Berlin-Tag von `now`. -function dayList(now, range) { - const [y, m, d] = dayOf(now).split('-').map(Number); +// 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(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10)); + 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); -export function computeStats(db, site, range, now = Date.now()) { - const days = dayList(now, range); +// 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 pvByDay = new Map(days.map((d) => [d, 0])); + const perDay = new Map(days.map((d) => [d, { pv: 0, visitors: 0, contacts: 0, en: 0, sec: 0, scroll: 0 }])); const dims = {}; - const bump = (dim, key, n) => { + 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 apply = (day, dim, key, n) => { - if (dim === 'pv_total') pvByDay.set(day, pvByDay.get(day) + n); - bump(dim, key, n); - }; - const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ?').all(site, first).map((r) => r.day)); - for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ?').all(site, first)) { + 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); } - // Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten + // 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(); - const from = now - (range + 1) * DAY_MS; 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, from, now)) { + .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 contacts = top(dims.ct_kind || new Map()); + 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()), }, - series: days.map((day) => ({ day, pv: pvByDay.get(day) })), - pages: top(dims.pv_path || new Map()), - refs: top(dims.pv_ref || new Map()), - countries: top(dims.pv_country || new Map()), - devices: top(dims.pv_device || new Map()), - contacts, - banner, }; } diff --git a/analytics/test/client.test.js b/analytics/test/client.test.js index 912b566..e438971 100644 --- a/analytics/test/client.test.js +++ b/analytics/test/client.test.js @@ -269,7 +269,7 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)' assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/); assert.match(css, /button\{[^}]*[; ]width:150px/); assert.equal((css.match(/(? 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,38 +133,193 @@ 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', 'pages', 'refs', 'series', '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 }); - 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 vor dem aktuellen Zeitraum, complete nach firstDay', async () => { + const { db, ev } = mk(); + // 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); + 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); + // 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 get(); + assert.equal(j.firstDay, '2026-09-26'); + assert.equal(j.previous.complete, true); + // 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[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); + a.close(); +}); + +test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { + const { db, ev } = mk(); + const a = await admin(db); + const first = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()); + let j = await first(); + assert.equal(j.firstDay, ''); + assert.equal(j.previous.complete, false); + // nur Rohdaten (heute) + ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + assert.equal((await first()).firstDay, '2026-10-10'); + // 40 Tage alt: nach dem Nachtlauf nur noch in daily (Rohdaten > 30 Tage gelöscht) + ev({ ts: NOW - 40 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + assert.equal((await first()).firstDay, '2026-08-31'); + runNightly(db, { now: NOW }); + assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw WHERE ts < ?').get(NOW - 31 * D).c, 0); + j = await first(); + assert.equal(j.firstDay, '2026-08-31'); + assert.equal(j.previous.complete, true); + a.close(); +}); + +test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => { + const { db, ev } = mk(); + const t = NOW - D; // 9.10. + ev({ ts: t, type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 30, scroll: 50 }); + ev({ ts: t + 1, type: 'en', path: '/a', vp: 'd', visitor: 'v2', sec: 91, scroll: 100 }); + const a = await admin(db); + const j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + 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[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); + assert.equal(leer.previous.totals.avgSec, null); + assert.equal(leer.previous.totals.avgScroll, null); + a.close(); +}); + +test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { + // range 30 um 23:30 Berlin: Zeitumstellung am 25.10. liegt im Zeitraum. Spätabends wäre now - 30 * 24 h + // 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, 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-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(); +}); + +test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { + const { db, ev } = mk(); + ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 1, 11), type: 'ct', path: '/', visitor: 'v1', kind: 'mail' }); + const a = await admin(db); + const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).previous; + const live = await get(); // noch nicht verdichtet: live aus den Rohdaten + assert.equal(live.totals.pv, 1); + runNightly(db, { now: NOW }); // jetzt aus daily + const verdichtet = await get(); + assert.equal(verdichtet.totals.pv, 1); + assert.deepEqual(verdichtet, live); + runNightly(db, { now: NOW + H }); + assert.deepEqual(await get(), live); a.close(); }); 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); diff --git a/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md new file mode 100644 index 0000000..707f69b --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md @@ -0,0 +1,456 @@ +# Analyse-Übersicht neu („Cockpit“) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Analyse-Seite im Kundenportal zeigt eine Übersicht im „Cockpit“-Stil: Klartext-Zusammenfassung, Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Kontakt-Aktionen, Datenbasis; Heatmap und Einstellungen in eigenen Reitern. + +**Architecture:** Der Collector (`analytics/src/stats.js`) liefert zusätzlich Vorzeitraum, `firstDay`, Tageswerte und Durchschnitte; der Portal-Server reicht das durch. Im Browser: reine Funktionen (`analyse-view.js`), reine HTML-Bausteine (`analyse-cards.js`), DOM-Verdrahtung (`analyse.js`), Stil (`analyse.css`). Die Analyse-Oberfläche zieht aus `funktionen.js` aus. + +**Tech Stack:** Node 24 + `node:sqlite` (Collector), Node 20 + Express (Portal), Vanilla-JS-ES-Module im Browser, `node --test`, Playwright (`playwright-core` aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core`). + +**Spec:** `docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md` + +## Global Constraints + +- Arbeitsort: Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu`, Branch `feature/analyse-uebersicht`. `node_modules` ist ein Symlink auf `../build/node_modules` und bleibt untracked: **nie `git add -A`**, immer Dateien einzeln adden. +- Keine neuen Abhängigkeiten. Keine Änderung an `analytics/client/*`, `public/rechtliches/*`, Banner, AVV, Leistungsbeschreibung, Datenschutztexten, WOMS, Schema, `.env`. +- Nur vorhandene Daten (Spec U4). Klartext nur durch feste Regeln im Browser (U5). +- Texte der Zustände Einschalten und Ausschalten bleiben **wörtlich** wie heute (bestehende Tests in `test/funktionenTiles.test.js` prüfen sie). +- Neue Texte: Deutsch, Du-Form klein („du“, „deine“), Zahlen `de-DE` („6.930“, „7,8“). +- Element-IDs bleiben: `#analyse-body`, `#an-stage`, `#an-preview`, `#an-avv`, `#an-on`, `#an-error`, `#an-color`, `#an-hint`, `#an-off`, `#an-off-*`, `#an-heat-page`. +- Alle Werte aus Daten (Seitenpfade, Hosts, Projektnamen) werden in HTML maskiert (`esc`). +- Akzent nur unter `.an`: `--an-acc: #2dd4bf`, `--an-acc-strong: #5eead4`, `--an-acc-soft: rgba(45,212,191,.1)`, weniger `#fda4af`, Karte `rgba(14,16,20,.92)`, Rand `rgba(255,255,255,.09)`, Radius 16 px, Abstand 14 px. +- Reiter-Schlüssel: `'uebersicht' | 'heatmap' | 'einstellungen'`. Zeiträume: 7, 30, 90. +- Portal-Tests laufen auf Node 20 (Produktion) und 24; Collector-Tests auf Node 24. +- Commit-Nachrichten enden mit `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Antwort eines **alten Collectors** ohne `previous`, `firstDay`, `from/to` und ohne `series[].visitors` (Deploy-Reihenfolge): Seite rendert ohne Chips und ohne Fehler, Mini-Verläufe nur, wo Werte da sind → Test in Task 4. +2. **Website ohne einen einzigen Aufruf** (alles 0, `consentRate` 0, `avgSec` null): keine `NaN`/`Infinity`/`undefined` im HTML, Leerzustand im Block „Das Wichtigste“, Ring 0 % → Test in Task 4. +3. **Seitenpfad oder Host mit HTML** (`/`, Host `a"b.de`): überall maskiert (Hinweise, Ranglisten, Heatmap-Auswahl) → Tests in Task 3 und Task 4. +4. **Schnelles Umschalten** von Zeitraum/Website: eine langsame ältere Antwort überschreibt die neuere nicht → Playwright-Check mit verzögerter Antwort in Task 5. +5. **Sehr langer Pfad** auf dem Handy (402 px): keine waagrechte Scrollleiste, Text gekürzt → Playwright-Check in Task 5. + +--- + +### Task 1: Collector liefert Vorzeitraum, firstDay, Tageswerte, Durchschnitte; Portal liefert activatedAt + +**Files:** +- Modify: `analytics/src/stats.js` (gesamte Datei, 69 Zeilen) +- Modify: `analytics/test/stats.test.js` (Test „Stats-Endpunkt: Form …“ ab Zeile 118 anpassen, neue Tests anhängen) +- Modify: `test/analyticsContract.test.js:42-50` +- Modify: `server/routes/funktionen.js:51-75` (`analyseView`) +- Modify: `test/analyseRoutes.test.js:115` (+ neuer Test) + +**Interfaces:** +- Produces: Antwort von `computeStats(db, site, range, now)` (und damit `GET /a/admin/stats/:key` und `GET /api/funktionen/analyse/stats`) mit den Feldern aus Spec Abschnitt 1: + `range`, `from`, `to`, `firstDay`, `totals { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll }`, `series [{ day, pv, visitors, contacts, avgSec }]`, `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `previous { from, to, complete, totals {…gleich…}, series […gleich…], contacts [{key,count}] }`. +- Produces: `p.analyse.activatedAt` (ISO-String oder `null`) in `GET /api/funktionen`. + +- [ ] **Step 1: Failing Tests im Collector schreiben** (`analytics/test/stats.test.js`, Helfer `mk`, `admin`, `KEY`, `NOW` = 10.10.2026 12:00 Berlin, `D`, `H` existieren) + +Bestehenden Test „Stats-Endpunkt: Form …“ anpassen: +```js +assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']); +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); +``` +Neue Tests: +```js +test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => { + // pv am 30.09. (im Vorzeitraum 27.09.–03.10.), en am 01.10., ct am 02.10., pv am 26.09. (vor dem Vorzeitraum) + // erst ohne 26.09.: firstDay '2026-09-30', complete false; dann mit 26.09.: firstDay '2026-09-26', 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); // 26.09. zählt nicht mit + assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]); + assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer +}); +test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { /* '' ohne Ereignisse; nach runNightly weiterhin gleich */ }); +test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => { + // zwei en am selben Tag: sec 30/scroll 50 und sec 91/scroll 100 → avgSec 61 (60,5 gerundet), avgScroll 75, engaged 2 +}); +test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { + const NOV = Date.UTC(2026, 10, 10, 10); // range 30 + // series[0].day '2026-10-12', series[29].day '2026-11-10', previous.series[29].day '2026-10-11', previous.series[0].day '2026-09-12' + // alle 60 Tage verschieden +}); +test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { /* Ereignis im Vorzeitraum vor und nach runNightly: previous.totals.pv bleibt 1 */ }); +``` +Die Ereignisse der Tests mit `ev({ ts, type, … })` einfügen; `runNightly(db, { now: NOW })` verdichtet Tage < heute. + +- [ ] **Step 2: Tests laufen lassen, sie schlagen fehl** + +Run: `cd analytics && node --test test/stats.test.js` +Expected: FAIL (fehlende Felder `previous`, `firstDay` …) + +- [ ] **Step 3: `computeStats(db, site, range, now = Date.now())` in `analytics/src/stats.js` umbauen** + +Aufteilen in eine Sammel-Funktion für eine beliebige Tagesliste und eine Formungs-Funktion: +- `dayList(endDay, range)` bildet Kalendertage per `Date.UTC(y, m-1, d-i)` (wie heute). Aktueller Zeitraum: `dayList(dayOf(now), range)`. Vorzeitraum: `dayList(, range)` – kalendarisch, nie `now - range*DAY_MS`. +- `collect(db, site, days, now)`: `daily` für `day IN days` und noch nicht in `agg_state` stehende Tage live aus `events_raw` (Abfrage begrenzt auf Mitternacht UTC des ersten Tags − 2 Tage bis letzter Tag + 2 Tage und `ts <= now`, dann nach `dayOf(ts)` gefiltert; `aggregateRows` aus `jobs.js` wie heute). Ergebnis je Tag: `pv_total`, `visitors`, `ct_kind`-Summe, `en_count`, `en_sec_sum`, `scroll_sum` sowie die Top-Dimensionen über alle Tage. +- Durchschnitte: `avgSec = Math.round(en_sec_sum / en_count)`, `avgScroll = Math.round(scroll_sum / en_count)`, beide `null` bei `en_count = 0`; `engaged = en_count`. +- `firstDay`: Minimum aus `SELECT MIN(day) FROM daily WHERE site = ?` und `dayOf(SELECT MIN(ts) FROM events_raw WHERE site = ?)`, `''` wenn beides fehlt. `previous.complete = firstDay !== '' && firstDay <= previous.from`. +- `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `consentRate` bleiben exakt wie bisher (nur für den aktuellen Zeitraum); `previous.contacts` = `top(ct_kind)` des Vorzeitraums. + +- [ ] **Step 4: Collector-Tests grün** + +Run: `cd analytics && npm test` +Expected: PASS (bisher 137 Tests + neue), 0 fail + +- [ ] **Step 5: Vertragstest ergänzen** (`test/analyticsContract.test.js` nach Zeile 50) + +```js +assert.equal(stats.range, 7) +assert.equal(stats.previous.series.length, 7) +assert.ok('firstDay' in stats && 'avgSec' in stats.totals) +assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d)) +``` +Run: `node --test test/analyticsContract.test.js` → PASS + +- [ ] **Step 6: Failing Test für activatedAt** (`test/analyseRoutes.test.js`) + +Zeile 115: erwartetes Objekt um `activatedAt: null` ergänzen. Im Test nach erfolgreichem `activate` (Zeile ~128–135): `assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/)`. +Run: `node --test test/analyseRoutes.test.js` → FAIL + +- [ ] **Step 7: `analyseView` in `server/routes/funktionen.js` gibt `activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null` zurück** + +Run: `node --test test/analyseRoutes.test.js` → PASS + +- [ ] **Step 8: Gesamte Portal-Suite** + +Run: `npm test` +Expected: PASS, 0 fail (Ausgangslage 2259 pass) + +- [ ] **Step 9: Commit** + +```bash +git add analytics/src/stats.js analytics/test/stats.test.js test/analyticsContract.test.js server/routes/funktionen.js test/analyseRoutes.test.js +git commit -m "Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht" +``` + +--- + +### Task 2: Analyse-Oberfläche zieht aus funktionen.js aus (ohne sichtbare Änderung) + +**Files:** +- Create: `public/fn-util.js` +- Create: `public/analyse.js` +- Modify: `public/funktionen.js` (Zeilen 6–7 Importe, 28–61 Helfer, 101–110 Analyse-Zustand, 611–980 Analyse-Abschnitt) +- Modify: `public/analyse-view.js` (Zustand und Auswahl-Helfer dazu) +- Modify: `public/app.js:1920` +- Modify: `test/funktionenTiles.test.js:8-16`, `test/analyseView.test.js:50-59` + +**Interfaces:** +- Produces: `public/fn-util.js` exportiert `esc(value): string`, `safeUrl(url): string`, `request(path, { method, body, raw }): Promise` (unverändert aus `funktionen.js` verschoben). +- Produces: `public/analyse-view.js` exportiert zusätzlich `analyseOf(project)`, `analyseState(a)`, `pickAnalyseProject(projects)` (verschoben) und `export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }` (gemeinsamer Zustand für Kachel und Analyse; ersetzt das bisherige `analyseSel` in `funktionen.js`). +- Produces: `public/analyse.js` exportiert `initAnalyse(root, { projectId })`, `renderAnalyseActivation(stage, project, reload)`, `renderAnalyseOff(box, projectId, reload)` (Verhalten unverändert). +- Importstil: `funktionen.js` importiert absolut (`'/fn-util.js'`, `'/analyse-view.js'`), `analyse.js` und `analyse-view.js` relativ (`'./fn-util.js'`, `'./analyse-view.js'`, `'./geraete.js'`) – so lassen sie sich in Node direkt importieren und sind im Browser dieselben Module. + +- [ ] **Step 1: Tests auf die neue Aufteilung umstellen (schlagen fehl)** + +`test/funktionenTiles.test.js`: zusätzlich `.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)`; `initAnalyse`, `renderAnalyseOff` aus `await import(fileUrl('analyse.js'))` statt aus `funktionen.mjs`. +`test/analyseView.test.js` („verdrahtet“): +```js +assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/) +const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8') +assert.doesNotMatch(an, /\bconfirm\(/) +const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') +assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/) +``` +Run: `node --test test/funktionenTiles.test.js test/analyseView.test.js` → FAIL + +- [ ] **Step 2: Verschieben** + +Abschnitt `/* --- Analyse --- */` (Zeile 611 bis Dateiende) und die Analyse-Importe aus `funktionen.js` nach `public/analyse.js`; `esc`/`safeUrl`/`request` nach `fn-util.js`; `analyseOf`/`analyseState`/`pickAnalyseProject` und `analyseSel` nach `analyse-view.js`. `funktionen.js` behält Kacheln und News und setzt beim Klick auf die Analyse-Kachel `analyseSel.projectId` (importiert). Vorher prüfen, wer die Funktionen sonst nutzt: `grep -a -rn "initAnalyse\|renderAnalyse\|analyseSel" public server test`. + +- [ ] **Step 3: `app.js:1920` lädt `/analyse.js`** + +`analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),` + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `npm test` → PASS, 0 fail + +- [ ] **Step 5: Sichtprüfung, dass nichts anders aussieht** + +Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task2` +Expected: alle Zeilen `OK`, keine `FAIL`. + +- [ ] **Step 6: Commit** + +```bash +git add public/fn-util.js public/analyse.js public/funktionen.js public/analyse-view.js public/app.js test/funktionenTiles.test.js test/analyseView.test.js +git commit -m "Analyse: Oberfläche in eigenes Modul analyse.js, gemeinsame Helfer in fn-util.js" +``` + +--- + +### Task 3: Reine Funktionen für Vergleich und Klartext + +**Files:** +- Modify: `public/analyse-view.js` +- Create: `test/analyseInsights.test.js` + +**Interfaces:** +- Consumes: Statistik-Form aus Task 1; `esc` aus `./fn-util.js`. +- Produces (alle in `public/analyse-view.js`, ohne DOM): + - `fmtNum(n): string` – `de-DE`, ganzzahlig. + - `fmtDuration(sec): string` – `'1:48'`, `'0:07'`; `null`/ungültig → `'–'`. + - `dayLabel(day): string` – `'2026-09-19'` → `'Sa., 19.09.'`. + - `periodLabel(from, to): string` – `'5. Sep. – 4. Okt.'` (Monatskürzel Jan., Feb., März, Apr., Mai, Juni, Juli, Aug., Sep., Okt., Nov., Dez.); leere Eingabe → `''`. + - `compareFrom(stats): { ok: boolean, from: string }` – `ok` wenn `stats.previous?.complete && stats.previous.totals.pv >= 20`; `from` = `'TT.MM.'` von `firstDay + (2 × range − 1)` Tagen nur wenn `previous` vorhanden, `!complete` und `firstDay`; sonst `''`. + - `delta(cur, prev, { kind = 'count' } = {}): { dir: 'up'|'down'|'flat', text: string, aria: string } | null` – Regeln Spec 2.4; `null`, wenn `cur` oder `prev` keine Zahl ist. + - `contactRate(contacts, visitors): number | null` – Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; `null` wenn `visitors < 30`. + - `refName(host): string` – Spec 2.1 (Google, Bing, Instagram, Facebook, DuckDuckGo, Ecosia, sonst Host; `''` → `'Direkt oder unbekannt'`). + - `weekdayStats(series, firstDay): { best: { weekday, name, avg } | null, avg: number, peak: { day, pv } | null } | null` – über Tage ≥ `firstDay` (alle, wenn `''`); `null` bei < 7 solchen Tagen; `best` nur wenn Schnitt ≥ 1,2 × Gesamtschnitt; `peak` nur wenn ≥ 1,8 × Gesamtschnitt. + - `summarize(stats): { lead: string, items: [{ icon: 'contact'|'day'|'source'|'device'|'page', html: string }] } | null` – Spec 2.3; `null` bei `totals.pv < 30`; `lead` und `html` sind fertiges, maskiertes HTML mit ``. + - `chartModel(series, opts)` bekommt die Option `compare` (Array `{ pv }`): gemeinsame Skala über beide Reihen, zusätzliches Feld `prevLine` (Pfad, `''` ohne `compare`), `points[i].prev` (Zahl oder `null`). + +- [ ] **Step 1: Failing Tests schreiben** (`test/analyseInsights.test.js`, Testdaten-Helfer `stats(over)` baut eine vollständige Antwort mit 30 Tagen) + +```js +test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => { + assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent + assert.equal(delta(45, 33).text, '▲ 12') + assert.equal(delta(30, 33).text, '▼ 3') + assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' }) + assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1 + assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s') + assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05') + assert.equal(delta(null, 3), null) +}) +test('fmtDuration, dayLabel, periodLabel, fmtNum', () => { + assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–') + assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.') + assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.') + assert.equal(fmtNum(6930), '6.930') +}) +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({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' }) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false) +}) +test('contactRate und refName', () => { + assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null) + assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example') +}) +test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { /* Samstage 2× Schnitt → best.name 'Samstag'; ein Tag 3× Schnitt → peak */ }) +test('summarize: Wachstum mit Prozent', () => { + const s = summarize(stats()) + assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – 14 % mehr als in den 30 Tagen davor.') + assert.equal(s.items[0].icon, 'contact') + assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.') + assert.ok(s.items.length <= 4) +}) +test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { /* '– 9 % weniger als', '– etwa so oft wie in den 30 Tagen davor.', '– 12 Aufrufe mehr als' */ }) +test('summarize: ohne Vergleich und neu gestartet', () => { + // previous fehlt: '…in den letzten 30 Tagen X-mal aufgerufen.' + // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website X-mal aufgerufen.' +}) +test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => { + assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null) + // contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' +}) +test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => { + // refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).' + // devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.' + // pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).' + // alle 5 Bedingungen wahr → Seite fällt weg +}) +test('summarize: Pfade und Hosts werden maskiert', () => { + const s = summarize(stats({ pages: [{ key: '/', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] })) + assert.ok(!JSON.stringify(s).includes(' { /* top über beide Reihen; ohne compare prevLine '' und bestehender Test unverändert */ }) +``` + +- [ ] **Step 2: Tests laufen, schlagen fehl** + +Run: `node --test test/analyseInsights.test.js` → FAIL (Exporte fehlen) + +- [ ] **Step 3: Funktionen in `public/analyse-view.js` implementieren** (Signaturen oben; `esc` aus `'./fn-util.js'`; Wochentagsnamen Sonntag…Samstag; Spitzentag-Satz „Spitzentag war Sa., 19.09. mit 371 Aufrufen.“ hängt am Wochentag-Hinweis, wenn beides zutrifft) + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `node --test test/analyseInsights.test.js test/analyseView.test.js && npm test` → PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/analyse-view.js test/analyseInsights.test.js +git commit -m "Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen" +``` + +--- + +### Task 4: HTML-Bausteine der Cockpit-Ansicht + +**Files:** +- Create: `public/analyse-cards.js` +- Create: `test/analyseCards.test.js` + +**Interfaces:** +- Consumes: Task 3 (`summarize`, `delta`, `compareFrom`, `contactRate`, `refName`, `fmtDuration`, `fmtNum`, `periodLabel`, `dayLabel`, `chartModel`, `barRows`, `countryName`, `flagEmoji`, `DEVICE_LABELS`), `esc`. +- Produces (alle geben HTML-Strings zurück, ohne DOM; relative Importe): + - `headerHtml({ project, usable, range, showRange, devMode, stamp })` – Kopfleiste `.an-top`; Auswahl `${sites.map((p) => ``).join('')}` + : `${esc(project?.projectName)}` + const seg = showRange + ? `
${RANGES.map((r) => ``).join('')}
` + : '' + return `
${name}${st.text}
${seg}
` +} + +const TABS = [['uebersicht', 'Übersicht'], ['heatmap', 'Heatmap'], ['einstellungen', 'Einstellungen']] + +export function tabsHtml(tab) { + const active = TABS.some(([k]) => k === tab) ? tab : 'uebersicht' + return `
${TABS.map(([k, label]) => ``).join('')}
` +} + +/* --- Das Wichtigste und Kennzahlen, Spec 2.1, 2.3, 2.4 --- */ + +export function summaryHtml(stats) { + const s = summarize(stats) + const period = periodLabel(stats?.from, stats?.to) + const head = `

Das Wichtigste${period ? ` · ${esc(period)}` : ''}

` + if (!s) return `
${head}

Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.

` + // lead und items[].html liefert summarize bereits maskiert + const points = s.items.length ? `
    ${s.items.map((i) => `
  • ${ico(i.icon)}${i.html}
  • `).join('')}
` : '' + return `
${head}

${s.lead}

${points}
` +} + +/** 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((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 `` +} + +// 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) => (gaps && !isNum(d?.[key]) ? null : num(d?.[key]))) : null +} + +export function kpisHtml(stats) { + const t = stats?.totals || {} + const cmp = compareFrom(stats) + const p = cmp.ok ? stats.previous.totals || {} : null + const tiles = [ + { key: 'pv', label: 'Seitenaufrufe', value: fmtNum(t.pv), d: p && delta(t.pv, p.pv), vs: p && fmtNum(p.pv) }, + { key: 'visitors', label: 'Besucher', star: true, value: fmtNum(t.visitors), d: p && delta(t.visitors, p.visitors), vs: p && fmtNum(p.visitors) }, + { key: 'contacts', label: 'Kontakt-Aktionen', star: true, value: fmtNum(t.contacts), d: p && delta(t.contacts, p.contacts), vs: p && fmtNum(p.contacts) }, + { key: 'time', label: 'Ø Zeit pro Seite', star: true, value: fmtDuration(t.avgSec), sub: isNum(t.avgScroll) ? `Ø ${fmtNum(t.avgScroll)} % gescrollt` : '', d: p && delta(t.avgSec, p.avgSec, { kind: 'sec' }), vs: p && fmtDuration(p.avgSec) }, + ] + const field = { pv: 'pv', visitors: 'visitors', contacts: 'contacts', time: 'avgSec' } + const tile = (k) => { + const foot = k.d ? `${chip(k.d)}vs. ${esc(k.vs)}` : !cmp.ok && cmp.from ? `Vergleich ab ${esc(cmp.from)}` : '' + return `
${k.label}${k.star ? '*' : ''}${esc(k.value)}${k.sub ? `${esc(k.sub)}` : ''}
${foot}
${sparkHtml(trend(stats?.series, field[k.key], k.key === 'time'))}
` + } + return `
${tiles.map(tile).join('')}
` +} + +/* --- Diagramm und Kontakt-Aktionen, Spec 2.1 --- */ + +/** Text der Blase ueber einem Tag: "Sa., 19.09.: 371 Aufrufe (davor 210)"; prev leer/null => ohne Klammer. */ +export function chartTipText(day, pv, prev) { + const n = num(pv) + const before = prev === null || prev === undefined || prev === '' ? '' : ` (davor ${fmtNum(prev)})` + return `${dayLabel(day)}: ${fmtNum(n)} ${n === 1 ? 'Aufruf' : 'Aufrufe'}${before}` +} + +export function chartHtml(stats) { + const series = list(stats?.series) + const cmp = compareFrom(stats) + const head = (legend) => `

Seitenaufrufe pro Tag

${legend}
` + if (!series.length) return `
${head('')}

Noch keine Daten.

` + // 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) + const title = `Seitenaufrufe pro Tag vom ${shortDay(m.points[0].day)} bis ${shortDay(m.points[m.points.length - 1].day)}, insgesamt ${fmtNum(total)}` + const legend = `
${days} Tage${hasPrev ? 'Zeitraum davor' : ''}
` + const dots = m.points.map((p, i) => ``).join('') + return `
${head(legend)}
${esc(title)}` + + m.yTicks.map((k) => `${esc(k.label)}`).join('') + + m.xLabels.map((l) => `${esc(l.label)}`).join('') + + `${hasPrev ? `` : ''}${dots}
` +} + +const CONTACT_KINDS = [['tel', 'Anrufe'], ['mail', 'E-Mails'], ['wa', 'WhatsApp'], ['out', 'Links nach außen']] + +export function contactsHtml(stats) { + const t = stats?.totals || {} + const total = num(t.contacts) + const cmp = compareFrom(stats) + const before = cmp.ok ? stats.previous : null + const head = `

Kontakt-Aktionen

${fmtNum(total)}
` + if (total < 1) return `
${head}

Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.

` + const countOf = (rows, key) => num(list(rows).find((c) => c?.key === key)?.count) + const rows = CONTACT_KINDS.map(([key, label]) => { + const cur = countOf(stats.contacts, key) + const was = before ? countOf(before.contacts, key) : 0 + if (!cur && !was) return '' + return `
  • ${ico(key)}${label}${fmtNum(cur)}${chip(before && delta(cur, was))}
  • ` + }).join('') + // Kontakt-Aktionen sind Klicks, keine Personen: "pro 100 Besucher"; ab 30 Besuchern + const rate = contactRate(total, num(t.visitors)) + const wasRate = before ? contactRate(num(before.totals?.contacts), num(before.totals?.visitors)) : null + const quote = rate === null ? '' : `

    Auf 100 Besucher kommen ${rateDe(rate)} Kontakt-Aktionen${wasRate === null ? '' : ` (davor ${rateDe(wasRate)})`}.

    ` + return `
    ${head}${rows ? `
      ${rows}
    ` : ''}${quote}
    ` +} + +/* --- Ranglisten, Spec 2.1 --- */ + +const CARDS = { + seen: { title: 'Was angesehen wird', tabs: [['pages', 'Seiten'], ['refs', 'Herkunft']] }, + who: { title: 'Wer zu Besuch ist', tabs: [['devices', 'Geräte'], ['countries', 'Länder']] }, +} +const NAMERS = { + pages: (key) => (key === '' || key === '/' ? 'Startseite' : key), + refs: refName, + devices: (key) => (Object.hasOwn(DEVICE_LABELS, key) ? DEVICE_LABELS[key] : 'Unbekannt'), + countries: countryName, +} +const SHORT = 6 +const LONG = 20 + +/** Rangliste "seen" (Seiten | Herkunft) oder "who" (Geraete | Laender); tab waehlt den Reiter, expanded zeigt bis 20 statt 6 Zeilen. */ +export function rankCardHtml(card, stats, { tab, expanded } = {}) { + if (!Object.hasOwn(CARDS, card)) return '' + const def = CARDS[card] + const active = def.tabs.some(([k]) => k === tab) ? tab : def.tabs[0][0] + const items = list(stats?.[active]) + 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 ? '< 1 %' : `${Math.round(pct)} %` + } + const all = barRows(items, NAMERS[active]) + const row = (r) => { + const flag = active === 'countries' && flagEmoji(r.key) ? ` ` : '' + return `
  • ${flag}${esc(r.label)}${fmtNum(r.count)}${share(r.count)}
  • ` + } + const tabs = def.tabs.map(([k, label]) => ``).join('') + const rows = all.length ? `
      ${all.slice(0, expanded ? LONG : SHORT).map(row).join('')}
    ` : '

    Noch keine Daten.

    ' + const more = all.length > SHORT && !expanded ? '' : '' + return `

    ${def.title}

    ${tabs}
    ${rows}${more}
    ` +} + +/* --- Datenbasis, Heatmap-Karte, Laden, Fehler --- */ + +export function basisHtml(stats) { + const b = stats?.banner || {} + const decided = num(b.accept) + num(b.reject) + const rate = decided > 0 ? Math.min(1, Math.max(0, isNum(stats?.totals?.consentRate) ? stats.totals.consentRate : num(b.accept) / decided)) : 0 + const pct = Math.round(rate * 100) + const main = decided > 0 + ? `${fmtNum(pct)} % haben dem Banner zugestimmt (${fmtNum(b.accept)} von ${fmtNum(decided)} Entscheidung${decided === 1 ? '' : 'en'}).` + : 'Noch hat niemand über das Banner entschieden.' + const full = 2 * Math.PI * 30 + const ring = `` + return `

    Datenbasis

    ${ring}

    ${main} Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.

    ` +} + +export function heatTeaserHtml() { + return '

    Heatmap

    Sieh auf deiner echten Website, wo geklickt wird – am Computer, Tablet und Handy.

    ' +} + +/** Platzhalter in der Form der echten Kacheln, solange die Auswertung laedt. */ +export function skeletonHtml() { + const card = (cls) => `` + return `
    Auswertung wird geladen
    ${card('an-kpi').repeat(4)}
    ${card('an-chartbox')}${card('an-contacts')}
    ${card('an-rank')}${card('an-rank')}
    ` +} + +export function errorHtml(message) { + return `` +} + +/* --- Reiter Einstellungen, Spec 2.2 --- */ + +const BERLIN_DATE = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) +// Links nur auf eigene Pfade oder http(s) +const hrefOk = (url) => typeof url === 'string' && /^(\/(?!\/)|https?:\/\/)/i.test(url) + +export function settingsHtml(project) { + const a = project?.analyse || {} + const color = bannerColor(a.accentColor) // wie auf der Website: zu helle Farbe => #111111 + const since = a.activatedAt ? new Date(a.activatedAt) : null + const status = `Analyse aktiv${since && !Number.isNaN(since.getTime()) ? ` seit ${BERLIN_DATE.format(since)}` : ''}` + const rows = [ + ['Status', esc(status)], + ['Farbe des Banners', ` ${color}`], + a.avvVersion ? ['Vereinbarung', `Fassung ${esc(a.avvVersion)}`] : null, + ].filter(Boolean) + const links = [[a.legal?.avv, 'Auftragsverarbeitungsvertrag Website-Analyse'], [a.legal?.lb, 'Leistungsbeschreibung Website-Analyse']] + .filter(([url]) => hrefOk(url)).map(([url, label]) => `
  • ${label}
  • `) + return `

    Status

    ${rows.map(([k, v]) => `
    ${k}
    ${v}
    `).join('')}
    ` + + `${links.length ? `

    Unterlagen

      ${links.join('')}
    ` : ''}
    ` +} + +/* --- Zusammenbau der Uebersicht --- */ + +export function overviewHtml(stats) { + return `
    ${summaryHtml(stats)}${kpisHtml(stats)}
    ${chartHtml(stats)}${contactsHtml(stats)}
    ` + + `
    ${rankCardHtml('seen', stats)}${rankCardHtml('who', stats)}
    ${basisHtml(stats)}${heatTeaserHtml()}
    ` + + '

    Länderdaten: IP-Geolocation by DB-IP

    ' +} diff --git a/public/analyse-view.js b/public/analyse-view.js index bc6f612..4e45b76 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -1,4 +1,6 @@ -/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ +/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ + +import { esc } from './fn-util.js' // Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1 export function contrastOk(hex) { @@ -53,22 +55,33 @@ export function shortDay(day) { return m ? `${m[2]}.${m[1]}.` : String(day || '') } -/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */ -export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) { +/** 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) => { + const v = before[i]?.pv + return v == null || !Number.isFinite(Number(v)) ? null : Number(v) + } const innerW = width - pad.l - pad.r const innerH = height - pad.t - pad.b - const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0))) + const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0), ...data.map((_, i) => prevOf(i) ?? 0))) const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2) 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 })) + 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) const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : '' - return { width, height, pad, top, points, xLabels, yTicks, line, area } + // Vergleichslinie: Luecken (fehlende Tage) beginnen einen neuen Abschnitt + const prevLine = points.reduce((path, p, i) => { + if (p.prev === null) return path + const move = i > 0 && points[i - 1].prev !== null ? 'L' : 'M' + return `${path}${path ? ' ' : ''}${move}${p.x} ${round(y(p.prev))}` + }, '') + return { width, height, pad, top, points, xLabels, yTicks, line, area, prevLine } } function round(n) { @@ -90,3 +103,251 @@ export function controllerLines(controller) { const c = controller || {} return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean) } + +/* Analyse-Zustand je Website (comingSoon = Schalter aus) und Auswahl */ +export const analyseOf = (project) => project.analyse || { comingSoon: true } +export const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') + +export function pickAnalyseProject(projects) { + return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0] +} + +// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js) +export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' } + +/* --- Vergleich zum Vorzeitraum und Klartext ("Das Wichtigste"), Spec 2.3 und 2.4 --- */ + +const MONTHS = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'] +const WEEKDAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] // wie Date#getUTCDay() +const WEEKDAYS_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'] +const AGAINST = 'gegenüber dem Zeitraum davor' + +const NUMBER_DE = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }) +const isNum = (v) => typeof v === 'number' && Number.isFinite(v) + +/** Ganzzahl auf Deutsch: 6930 => "6.930". */ +export const fmtNum = (n) => NUMBER_DE.format(Number(n) || 0) + +/** Sekunden als m:ss: 108 => "1:48", 7 => "0:07"; null oder ungueltig => "–". */ +export function fmtDuration(sec) { + if (!isNum(sec) || sec < 0) return '–' + const s = Math.round(sec) + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` +} + +// "YYYY-MM-DD" => { y, m, d } oder null; Wochentage und Tagesrechnung laufen nur ueber Date.UTC (keine Ortszeit) +function parseDay(day) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day || '') + if (!m) return null + const [y, mo, d] = [+m[1], +m[2], +m[3]] + const t = new Date(Date.UTC(y, mo - 1, d)) + return t.getUTCMonth() === mo - 1 && t.getUTCDate() === d ? { y, m: mo, d } : null +} +const weekdayOf = (day) => { + const p = parseDay(day) + return p ? new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() : -1 +} +function addDays(day, n) { + const p = parseDay(day) + return p ? new Date(Date.UTC(p.y, p.m - 1, p.d + n)).toISOString().slice(0, 10) : '' +} + +/** "2026-09-19" => "Sa., 19.09." */ +export function dayLabel(day) { + const w = weekdayOf(day) + return w < 0 ? String(day || '') : `${WEEKDAYS_SHORT[w]}, ${shortDay(day)}` +} + +/** ("2026-09-05", "2026-10-04") => "5. Sep. – 4. Okt."; leere oder ungueltige Eingabe => "". */ +export function periodLabel(from, to) { + const a = parseDay(from) + const b = parseDay(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). + 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) + return { ok: false, from: from ? shortDay(from) : '' } + } + return { ok: false, from: '' } +} + +// Sekunden in Worten fuer Screenreader: 65 => "1 Minute 5 Sekunden" +function secWords(sec) { + const m = Math.floor(sec / 60) + const s = sec % 60 + return [m ? `${m} ${m === 1 ? 'Minute' : 'Minuten'}` : '', s || !m ? `${s} ${s === 1 ? 'Sekunde' : 'Sekunden'}` : ''].filter(Boolean).join(' ') +} + +/** Veraenderung gegenueber dem Vorwert fuer den Chip: { dir, text, aria } oder null, wenn ein Wert keine Zahl ist. kind 'count': Prozent ab 100/100, sonst absolut; kind 'sec': absolut in Sekunden. */ +export function delta(cur, prev, { kind = 'count' } = {}) { + if (!isNum(cur) || !isNum(prev)) return null + const flat = (text) => ({ dir: 'flat', text, aria: `unverändert ${AGAINST}` }) + const diff = cur - prev + if (diff === 0) return flat('± 0') + const dir = diff > 0 ? 'up' : 'down' + const arrow = diff > 0 ? '▲' : '▼' + const sign = diff > 0 ? 'plus' : 'minus' + if (kind === 'sec') { + const n = Math.round(Math.abs(diff)) + if (n === 0) return flat('± 0') + return { dir, text: `${arrow} ${n < 60 ? `${n} s` : fmtDuration(n)}`, aria: `${sign} ${secWords(n)} ${AGAINST}` } + } + if (cur >= 100 && prev >= 100) { + const pct = (Math.abs(diff) / prev) * 100 + if (pct < 1) return flat('± 0 %') + const n = Math.round(pct) + return { dir, text: `${arrow} ${n} %`, aria: `${sign} ${n} Prozent ${AGAINST}` } + } + const n = fmtNum(Math.abs(diff)) + return { dir, text: `${arrow} ${n}`, aria: `${sign} ${n} ${AGAINST}` } +} + +/** Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; null unter 30 Besuchern (zu unsicher). */ +export function contactRate(contacts, visitors) { + if (!isNum(contacts) || !isNum(visitors) || visitors < 30) return null + return Math.round((contacts / visitors) * 1000) / 10 +} + +// Bekannte Herkunfts-Hosts (Teilstring am Label-Rand: "l.facebook.com" ja, "notgoogle.com" nein) +const REF_NAMES = [ + [/(^|\.)google\.(com?\.)?[a-z]{2,3}$/, 'Google'], + [/(^|\.)bing\.com$/, 'Bing'], + [/(^|\.)instagram\.com$/, 'Instagram'], + [/(^|\.)facebook\.com$/, 'Facebook'], + [/(^|\.)duckduckgo\.com$/, 'DuckDuckGo'], + [/(^|\.)ecosia\.org$/, 'Ecosia'], +] + +/** Lesbarer Name einer Herkunft; unbekannter Host bleibt unveraendert (beim Einsetzen in HTML maskieren). */ +export function refName(host) { + const h = String(host ?? '').trim() + if (!h) return 'Direkt oder unbekannt' + const hit = REF_NAMES.find(([re]) => re.test(h.toLowerCase())) + return hit ? hit[1] : h +} + +/** 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)) + .map((d) => ({ day: d.day, pv: Number(d.pv) || 0 })) + if (rows.length < 7) return null + const avg = rows.reduce((a, r) => a + r.pv, 0) / rows.length + const perWeekday = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 })) + for (const r of rows) { + const w = perWeekday[weekdayOf(r.day)] + w.sum += r.pv + w.n += 1 + } + let best = null + perWeekday.forEach((w, weekday) => { + if (w.n && (!best || w.sum / w.n > best.avg)) best = { weekday, name: WEEKDAYS[weekday], avg: w.sum / w.n } + }) + if (!best || !(avg > 0) || best.avg < 1.2 * avg) best = null + const max = rows.reduce((m, r) => (r.pv > m.pv ? r : m), rows[0]) + const peak = avg > 0 && max.pv >= 1.8 * avg ? { day: max.day, pv: max.pv } : null + return { best, avg, peak } +} + +// 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 (), alle Zahlen sind fmtNum +function leadOf(stats, cmp) { + const days = Number(stats.range) || (Array.isArray(stats.series) ? stats.series.length : 0) + const pv = Number(stats.totals.pv) + const count = `${fmtNum(pv)}-mal` + if (!cmp.ok) { + return stats.firstDay && stats.from && stats.firstDay >= stats.from + ? `Seit dem Start der Analyse am ${esc(shortDay(stats.firstDay))} wurde deine Website ${count} aufgerufen.` + : `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen.` + } + const before = Number(stats.previous.totals.pv) + const diff = pv - before + const start = `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen – ` + if (Math.abs(diff) / before < 0.05) return `${start}etwa so oft wie in den ${days} Tagen davor.` + const more = diff > 0 + const size = pv >= 100 && before >= 100 + ? `${fmtNum(Math.round((Math.abs(diff) / before) * 100))} %` + : `${fmtNum(Math.abs(diff))} ${Math.abs(diff) === 1 ? 'Aufruf' : 'Aufrufe'}` + return `${start}${size} ${more ? 'mehr' : 'weniger'} als in den ${days} Tagen davor.` +} + +function contactItem(stats, cmp) { + 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 = 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.` + } + return text +} + +function dayItem(stats) { + if (!(Number(stats.totals.pv) >= 70)) return null + 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.`) + if (w.peak) parts.push(`Spitzentag war ${dayLabel(w.peak.day)} mit ${fmtNum(w.peak.pv)} Aufrufen.`) + return parts.length ? parts.join(' ') : null +} + +function sourceItem(stats) { + const refs = Array.isArray(stats.refs) ? stats.refs : [] + const sum = refs.reduce((a, r) => a + (Number(r.count) || 0), 0) + const first = refs.find((r) => r.key) + if (!first || !(sum > 0) || first.count / sum < 0.2) return null + return `Die meisten, die über einen Link kamen, kommen von ${esc(refName(first.key))} (${fmtNum(first.count)}).` +} + +function deviceItem(stats) { + const devices = Array.isArray(stats.devices) ? stats.devices : [] + const sum = devices.reduce((a, d) => a + (Number(d.count) || 0), 0) + const main = devices.find((d) => DEVICE_WORDS[d.key] && sum > 0 && d.count / sum >= 0.55) + return main ? `${fmtNum(Math.round((main.count / sum) * 100))} % der Aufrufe kommen ${DEVICE_WORDS[main.key]}.` : null +} + +function pageItem(stats) { + const [first, second] = Array.isArray(stats.pages) ? stats.pages : [] + if (!first?.key) return null + if (first.key === '/') { + return second?.key + ? `Am meisten angesehen: die Startseite, danach ${esc(second.key)} (${fmtNum(second.count)}).` + : `Am meisten angesehen: die Startseite (${fmtNum(first.count)} Aufrufe).` + } + return `Am meisten angesehen: ${esc(first.key)} (${fmtNum(first.count)} Aufrufe).` +} + +/** Klartext fuer "Das Wichtigste": { lead, items: [{ icon, html }] } als maskiertes HTML; null unter 30 Seitenaufrufen. Feste Regeln, keine KI. */ +export function summarize(stats) { + if (!stats?.totals || !(Number(stats.totals.pv) >= 30)) return null + const cmp = compareFrom(stats) + const items = [ + ['contact', contactItem(stats, cmp)], + ['day', dayItem(stats)], + ['source', sourceItem(stats)], + ['device', deviceItem(stats)], + ['page', pageItem(stats)], + ].filter(([, html]) => html).slice(0, 4).map(([icon, html]) => ({ icon, html })) + return { lead: leadOf(stats, cmp), items } +} diff --git a/public/analyse.css b/public/analyse.css new file mode 100644 index 0000000..15e08ef --- /dev/null +++ b/public/analyse.css @@ -0,0 +1,295 @@ +/* Analyse "Cockpit" (analyse.js, analyse-cards.js): alles unter dem Wurzelelement .an mit eigenen Variablen und eigenem tuerkisen Akzent; + ausserhalb von #view-analyse aendert sich nichts. Klassen und Zustaende: Vertrag im Kopf von analyse-cards.js. */ + +.an { + --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.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); + --an-acc: #2dd4bf; + --an-acc-strong: #5eead4; + --an-acc-soft: rgba(45, 212, 191, 0.1); + --an-on-acc: #04201c; + --an-up-bg: rgba(45, 212, 191, 0.14); + --an-down: #fda4af; + --an-down-bg: rgba(244, 63, 94, 0.14); + --an-prev: rgba(255, 255, 255, 0.35); + --an-warn: #fcd34d; + --an-bar-fill: linear-gradient(90deg, rgba(45, 212, 191, 0.32), rgba(45, 212, 191, 0.12)); + --an-r: 16px; + --an-gap: 14px; + min-width: 0; + color: var(--an-fg); + color-scheme: dark; +} +.an, .an *, .an *::before, .an *::after { box-sizing: border-box; } +.an a, .an .kb-link { color: var(--an-acc-strong); } +.an :is(button, select, input, a):focus-visible { outline: 2px solid var(--an-acc-strong); outline-offset: 2px; } + +/* ------------------------------------------------------------ Kopfleiste --- */ +.an-top { display: flex; align-items: center; justify-content: space-between; gap: 12px 16px; flex-wrap: wrap; padding: 14px 16px; border-radius: var(--an-r); background: var(--an-card); border: 1px solid var(--an-line); } +.an-site { --an-dot: #2dd4bf; --an-dot-soft: rgba(45, 212, 191, 0.18); display: flex; align-items: center; gap: 12px; flex: 1 1 260px; min-width: 0; } +.an-site[data-state="warn"] { --an-dot: #fcd34d; --an-dot-soft: rgba(252, 211, 77, 0.18); } +.an-site[data-state="off"] { --an-dot: rgba(255, 255, 255, 0.4); --an-dot-soft: rgba(255, 255, 255, 0.1); } +.an-site > div { min-width: 0; } +.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; + background: transparent url("data:image/svg+xml;utf8,") no-repeat right 2px center / 12px; +} +.an-pick option { background: #14161b; color: #fff; } + +.an-seg { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 11px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--an-line); } +.an-seg button { padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--an-muted); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background-color 0.15s, color 0.15s; } +.an-seg button:hover { color: var(--an-fg); } +.an-seg button.on { background: var(--an-acc); color: var(--an-on-acc); } + +/* ----------------------------------------------------------------- Reiter --- */ +.an-tabs { display: flex; gap: 22px; margin: 18px 4px; border-bottom: 1px solid var(--an-line); overflow-x: auto; scrollbar-width: none; } +.an-tabs::-webkit-scrollbar { display: none; } +.an-tabbtn { flex: none; padding: 0 0 12px; border: 0; background: transparent; color: var(--an-muted); font: inherit; font-size: 14px; font-weight: 600; white-space: nowrap; cursor: pointer; transition: color 0.15s; } +.an-tabbtn:hover { color: var(--an-fg); } +.an-tabbtn.on { color: var(--an-fg); box-shadow: inset 0 -2px 0 var(--an-acc); } +.an .an-tabbtn:focus-visible { outline-offset: -2px; border-radius: 4px; } +.an-top + #an-stage { margin-top: var(--an-gap); } + +/* ------------------------------------------------------- Karten und Raster --- */ +.an-ov { display: grid; gap: var(--an-gap); min-width: 0; } +.an-ov > * { min-width: 0; } +.an-card { min-width: 0; padding: 20px 22px; border-radius: var(--an-r); background: var(--an-card); border: 1px solid var(--an-line); } +.an-card h3 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--an-muted); } +.an-card > h3 { margin-bottom: 14px; } +.an-head { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin-bottom: 14px; } +.an-empty { margin: 0; color: var(--an-muted); font-size: 14px; line-height: 1.5; } +.an-credit { margin: 2px 4px 0; font-size: 11.5px; line-height: 1.5; color: var(--an-faint); } +.an .an-credit a { color: inherit; } + +/* Das Wichtigste */ +.an-sum { padding: 22px 24px; border-radius: var(--an-r); background: linear-gradient(135deg, rgba(45, 212, 191, 0.13), rgba(139, 92, 246, 0.1) 60%, rgba(14, 16, 20, 0.9)); border: 1px solid rgba(94, 234, 212, 0.35); } +.an-eyebrow { margin: 0 0 10px; font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--an-acc); } +.an-lead { margin: 0 0 14px; font-size: 21px; line-height: 1.4; font-weight: 600; } +.an-sum-empty .an-lead { margin: 0; font-size: 17px; font-weight: 500; color: var(--an-fg2); } +.an-lead b { color: var(--an-acc-strong); white-space: nowrap; } +.an-points { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 22px; margin: 0; padding: 0; list-style: none; } +.an-points li { display: flex; align-items: flex-start; gap: 10px; color: var(--an-fg2); line-height: 1.45; } +.an-points li > span:last-child { min-width: 0; overflow-wrap: anywhere; } +.an-points b { color: var(--an-fg); } +.an-point-ico { flex: none; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; background: var(--an-acc-soft); color: var(--an-acc-strong); } +.an-ico { width: 15px; height: 15px; } + +/* Kennzahlen */ +.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--an-gap); } +.an-kpi { display: flex; flex-direction: column; padding: 16px 18px 12px; } +.an-k { display: flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 500; color: var(--an-muted); } +.an-star { font-size: 11px; color: var(--an-faint); } +.an-v { display: block; margin: 6px 0 4px; font-size: 30px; font-weight: 700; line-height: 1.15; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; } +.an-sub { display: block; margin: -2px 0 4px; font-size: 12px; color: var(--an-muted); } +.an-cmp { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 6px; min-height: 24px; margin-bottom: 10px; } +.an-vs { font-size: 12px; color: var(--an-faint); } +.an-delta { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; } +.an-delta.up { background: var(--an-up-bg); color: var(--an-acc-strong); } +.an-delta.down { background: var(--an-down-bg); color: var(--an-down); } +.an-delta.flat { background: var(--an-acc-soft); color: var(--an-muted); } +.an-spark { display: block; width: 100%; height: 38px; margin-top: auto; } +.an-spark-area { fill: var(--an-acc); opacity: 0.14; stroke: none; } +.an-spark-line { stroke: var(--an-acc); stroke-width: 1.6px; stroke-linejoin: round; stroke-linecap: round; } + +/* Diagramm und Kontakt-Aktionen: gleich hoch, das Diagramm fuellt die Karte */ +.an-row2 { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr); gap: var(--an-gap); align-items: stretch; } +.an-chartbox, .an-contacts { display: flex; flex-direction: column; } +.an-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; font-weight: 500; color: var(--an-muted); } +.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; 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; } +/* Punkte erst beim Zeigen oder Antippen (analyse.js setzt "on"); der Rand ist ein weicher Ring */ +.an-dot { fill: var(--an-acc-strong); stroke: rgba(45, 212, 191, 0.3); stroke-width: 9px; vector-effect: non-scaling-stroke; opacity: 0; pointer-events: none; } +.an-dot.on { opacity: 1; } +.an-tip { position: absolute; z-index: 3; padding: 6px 10px; border-radius: 8px; background: #0b0d10; border: 1px solid var(--an-line2); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5); color: var(--an-fg); font-size: 12.5px; font-weight: 600; white-space: nowrap; pointer-events: none; transform: translate(-50%, calc(-100% - 12px)); } +.an-tip.below { transform: translate(-50%, 12px); } +.an-tip[hidden] { display: none; } + +.an-sum-n { font-size: 15px; font-weight: 700; color: var(--an-fg2); font-variant-numeric: tabular-nums; } +.an-crows { margin: 0 0 12px; padding: 0; list-style: none; } +.an-crow { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--an-line); } +.an-cico { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; background: var(--an-acc-soft); color: var(--an-acc-strong); } +.an-cico .an-ico { width: 17px; height: 17px; } +.an-cname { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.an-crow b { font-size: 16px; font-variant-numeric: tabular-nums; text-align: right; } +.an-quote { margin: auto 0 0; padding: 10px 12px; border-radius: 10px; background: var(--an-acc-soft); color: var(--an-fg2); font-size: 13px; line-height: 1.45; } + +/* Ranglisten */ +.an-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--an-gap); } +.an-ltabs { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 12px; } +.an-ltab { padding: 6px 11px; border: 1px solid var(--an-line); border-radius: 8px; background: transparent; color: var(--an-muted); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: background-color 0.15s, color 0.15s; } +.an-ltab:hover { color: var(--an-fg); } +.an-ltab.on { background: var(--an-acc-soft); border-color: var(--an-line2); color: var(--an-fg); } +.an-lrows { margin: 0; padding: 0; list-style: none; } +.an-lr { display: grid; grid-template-columns: minmax(0, 1fr) auto 52px; align-items: center; gap: 12px; padding: 7px 0; } +.an-lab { position: relative; display: block; min-width: 0; padding: 5px 8px; border-radius: 7px; overflow: hidden; } +.an-fill { position: absolute; inset: 0 auto 0 0; border-radius: 7px; background: var(--an-bar-fill); } +.an-name { position: relative; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.an-lr b { font-size: 14px; font-variant-numeric: tabular-nums; } +.an-lr em { font-size: 12px; font-style: normal; text-align: right; color: var(--an-muted); font-variant-numeric: tabular-nums; } +.an-more { margin-top: 8px; padding: 7px 12px; border: 1px solid var(--an-line); border-radius: 8px; background: transparent; color: var(--an-fg2); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; } +.an-more:hover { color: var(--an-fg); border-color: var(--an-line2); } + +/* Datenbasis und Heatmap-Karte */ +.an-basis-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 18px; } +.an-ring { width: 74px; height: 74px; } +.an-ring-bg { stroke: rgba(255, 255, 255, 0.1); } +.an-ring-fg { stroke: var(--an-acc); } +.an-ring-n { fill: var(--an-fg); font-size: 15px; font-weight: 700; } +.an-basis-text { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--an-fg2); } +.an-basis-main { display: block; margin-bottom: 2px; font-weight: 600; color: var(--an-fg); } +.an-teaser-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 18px; } +.an-teaser-row p { flex: 1 1 170px; min-width: 0; margin: 0; line-height: 1.5; color: var(--an-fg2); } +.an-mini-heat { + flex: none; width: 110px; height: 74px; border-radius: 10px; border: 1px solid var(--an-line); + background: + radial-gradient(circle at 22% 40%, rgba(255, 60, 40, 0.9), rgba(255, 200, 0, 0.5) 12%, transparent 24%), + radial-gradient(circle at 70% 62%, rgba(255, 60, 40, 0.85), rgba(255, 200, 0, 0.5) 10%, transparent 22%), + radial-gradient(circle at 48% 25%, rgba(80, 220, 120, 0.7), transparent 14%), #1b1d22; +} +.an-btn { flex: none; padding: 10px 16px; border: 0; border-radius: 10px; background: var(--an-acc); color: var(--an-on-acc); font: inherit; font-size: 13px; font-weight: 700; white-space: nowrap; cursor: pointer; transition: background-color 0.15s; } +.an-btn:hover { background: var(--an-acc-strong); } + +/* Laden und Fehler */ +.an-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.an-sk { position: relative; overflow: hidden; } +.an-sk > i { display: block; height: 14px; margin-top: 12px; border-radius: 7px; background: rgba(255, 255, 255, 0.07); } +.an-sk > i:first-child { width: 38%; margin-top: 0; } +.an-sk > i:nth-child(2) { width: 62%; height: 28px; } +.an-sk > i:nth-child(3) { width: 85%; } +.an-sk::after { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.05) 50%, transparent 70%) 150% 0 / 200% 100%; animation: an-shimmer 1.4s linear infinite; } +@keyframes an-shimmer { to { background-position: -50% 0; } } +.an-skeleton .an-sum { min-height: 150px; } +.an-skeleton .an-kpi { min-height: 128px; } +.an-skeleton .an-chartbox, .an-skeleton .an-contacts { min-height: 300px; } +.an-skeleton .an-rank { min-height: 230px; } +.an-error { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px 16px; } +.an-error p { margin: 0; } + +/* ---------------------------------------------------------- Einstellungen --- */ +.an-dl { margin: 0; } +.an-dl > div { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 10px 0; border-top: 1px solid var(--an-line); } +.an-dl dt { color: var(--an-muted); } +.an-dl dd { margin: 0; text-align: right; overflow-wrap: anywhere; } +.an-swatch { display: inline-block; width: 14px; height: 14px; margin-right: 4px; border-radius: 4px; border: 1px solid var(--an-line2); vertical-align: -2px; } +.an-docs ul { margin: 0; padding: 0; list-style: none; } +.an-docs li { padding: 8px 0; border-top: 1px solid var(--an-line); } +#an-off p { margin: 0 0 12px; line-height: 1.55; color: var(--an-fg2); } +.an-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; } +.an-ghost { padding: 9px 16px; border: 1px solid var(--an-line2); border-radius: 10px; background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; } +.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); } +.an-state p:last-child { margin-bottom: 0; } +.an-panel > p, .an-state p { max-width: 75ch; } +.an-panel > h3, .an-state > h3 { margin: 0 0 10px; font-size: 20px; font-weight: 700; letter-spacing: 0; text-transform: none; color: var(--an-fg); } +.an-h4 { margin: 20px 0 8px; font-size: 14px; font-weight: 700; } +.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } +.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--an-line2); background: transparent; cursor: pointer; } +.an-presets { display: flex; flex-wrap: wrap; gap: 8px; } +.an-preset { width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 2px solid var(--an-line2); cursor: pointer; } +.an-hint { margin: 10px 0 0 !important; font-size: 13px; color: var(--an-warn) !important; } +.an-ctl { margin: 14px 0; padding: 12px 14px; border-radius: 10px; background: var(--an-acc-soft); } +.an-ctl p { margin: 0 0 6px; } +.an-ctl p:last-child { margin: 0; } +.an .fn-consent { max-width: 75ch; color: var(--an-fg2); } +.an .fn-consent input { accent-color: var(--an-acc); } +.an .abo-btn { background: var(--an-acc); color: var(--an-on-acc); border-radius: 10px; } +.an .abo-btn:hover { background: var(--an-acc-strong); } +/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */ +.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } +.an .an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; max-width: none; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; } +.an-bn-link { text-decoration: underline; } +.an-bn-row { display: flex; gap: 10px; flex: none; } +.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 150px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } + +/* ---------------------------------------------------------------- Heatmap --- */ +/* Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */ +.an-heat > p { margin: 0 0 14px; line-height: 1.55; color: var(--an-fg2); } +.an-heat .hs-head { margin-bottom: 10px; } +.an-heat-bar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.an-heat-bar label { display: grid; gap: 4px; flex: 1 1 220px; min-width: 0; max-width: 420px; font-size: 13px; font-weight: 600; } +.an-heat-bar select { width: 100%; min-width: 0; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--an-line2); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; text-overflow: ellipsis; } +.an-heat-bar select option { background: #14161b; color: #fff; } +.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; } +.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; } +.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; } +.hs-frame { width: 100%; } +.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--an-muted); text-align: center; } +.hs-dev .dm-canvas { background: #fff; } +.hs-site { position: absolute; left: 0; background: #fff; } +.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; } +@media (max-width: 640px) { + .hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; } + .hs-dev--phone .hs-frame { width: 62%; } +} + +/* ------------------------------------------------------------ Mittlere Breite --- */ +@media (max-width: 1100px) { + .an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .an-row2 { grid-template-columns: minmax(0, 1fr); } +} +/* Heatmap-Karte: Vorschau, Text und Knopf in einer Zeile, solange die Karte breit genug ist */ +@media (min-width: 1101px) { + .an-teaser-row { flex-wrap: nowrap; } + .an-teaser-row p { flex-basis: 0; } +} + +/* ------------------------------------------------------------------- Handy --- */ +@media (max-width: 880px) { + .an-top { padding: 14px; } + .an-seg { display: flex; width: 100%; } + .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; } + .an-row2, .an-pair, .an-points { grid-template-columns: minmax(0, 1fr); } + .an-sum { padding: 18px 16px; } + .an-lead { font-size: 19px; } + .an-sum-empty .an-lead { font-size: 16px; } + .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; } + .an-bn-btn { width: 100%; } +} + +@media (prefers-reduced-motion: reduce) { + .an *, .an *::before, .an *::after { transition: none !important; animation: none !important; } +} diff --git a/public/analyse.js b/public/analyse.js new file mode 100644 index 0000000..d7da4ae --- /dev/null +++ b/public/analyse.js @@ -0,0 +1,518 @@ +/* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen). + initAnalyse(rootEl, { projectId }): Rahmen "Cockpit" (.an) mit Kopfleiste; je nach Zustand der Analyse Einschalten (off), + Hinweis (disabled/blocked/unknown) oder, bei aktiver Analyse, die Reiter Uebersicht | Heatmap | Einstellungen. + HTML-Bausteine: analyse-cards.js, reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: analyse.css. + Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ + +import { esc, safeUrl, request } from './fn-util.js' +import { analyseOf, pickAnalyseProject, analyseSel, bannerColor, BANNER_PREVIEW_TEXT, controllerLines } from './analyse-view.js' +import { headerHtml, tabsHtml, overviewHtml, rankCardHtml, skeletonHtml, errorHtml, settingsHtml, chartTipText } from './analyse-cards.js' +import { HEAT_DEVICES, deviceHtml, heatCaption } from './geraete.js' +import { ZU_VERTRAEGE } from './vertraege-link.js' + +const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] +const HEX_RE = /^#[0-9a-f]{6}$/i +const nf = new Intl.NumberFormat('de-DE') +const TABS = ['uebersicht', 'heatmap', 'einstellungen'] +const RANGES = [7, 30, 90] +const CLOCK = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + +const showError = (root) => (err) => { root.innerHTML = `

    ${esc(err.message)}

    ` } + +export async function initAnalyse(root, { projectId } = {}) { + if (!root) return + if (projectId) analyseSel.projectId = projectId + const { projects = [] } = await request('/api/funktionen') + const usable = projects.filter((p) => !analyseOf(p).comingSoon) + if (!usable.length) { + root.innerHTML = '

    Die Analyse ist für deine Websites noch nicht freigeschaltet.

    ' + return + } + const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) + analyseSel.projectId = project.projectId + const a = analyseOf(project) + const active = a.state === 'active' + const tab = TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht' + root.innerHTML = `
    ${headHtml(project, usable, { showRange: active && tab === 'uebersicht' })}${active ? tabsHtml(tab) : ''}
    ` + const stage = root.querySelector('#an-stage') + const reload = () => initAnalyse(root).catch(showError(root)) + // Website wechseln: der Reiter bleibt (analyseSel.tab) + root.querySelector('.an')?.addEventListener('change', (ev) => { + if (ev.target?.id !== 'an-project') return + analyseSel.projectId = ev.target.value + reload() + }) + if (a.state === 'active') mountCockpit({ an: root.querySelector('.an'), stage, project, usable, reload }) + else if (a.state === 'disabled') stage.innerHTML = '

    Analyse ist abgeschaltet

    Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

    ' + else if (a.state === 'unknown') stage.innerHTML = '

    Die Daten konnten gerade nicht geladen werden.

    ' + else if (!a.eligible) stage.innerHTML = `

    Analyse nicht verfügbar

    ${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ` : ''}

    ` + else renderAnalyseActivation(stage, project, reload) +} + +// 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, today }) +} + +function bannerPreview(color) { + const c = bannerColor(color) + const btn = `style="background:${c};border-color:${c};color:#fff"` + return ` + ` +} + +const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' + +// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt +function controllerBlock(controller) { + if (controller.ok !== true) return `` + return `

    Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

    ${controllerLines(controller).map(esc).join(' · ')}

    Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

    ` +} + +export function renderAnalyseActivation(stage, project, reload) { + const a = analyseOf(project) + const legal = a.legal || {} + const controller = a.controller || { ok: false } + let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' + stage.innerHTML = ` +
    +

    Analyse für ${esc(project.projectName)} einschalten

    +

    Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

    +

    Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.

    +

    Farbe des Banners

    +
    + +
    ${PRESET_COLORS.map((c) => ``).join('')}
    +
    + +
    + ${controllerBlock(controller)} + + +
    +
    ` + const input = stage.querySelector('#an-color') + const hint = stage.querySelector('#an-hint') + const paint = () => { + stage.querySelector('#an-preview').innerHTML = bannerPreview(color) + hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) + } + paint() + input.addEventListener('input', () => { color = input.value; paint() }) + stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) + const box = stage.querySelector('#an-avv') + const button = stage.querySelector('#an-on') + const error = stage.querySelector('#an-error') + box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) + button.addEventListener('click', async () => { + if (!box.checked || controller.ok !== true) return + button.disabled = true + error.classList.add('hidden') + try { + await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) + reload() + } catch (err) { + error.textContent = err.message + error.classList.remove('hidden') + button.disabled = !box.checked || controller.ok !== true + } + }) +} + +/* --- Aktiver Zustand: Reiter Uebersicht | Heatmap | Einstellungen --- + Zustand je Aufruf von initAnalyse in der Closure von mountCockpit; Ereignisse am Wurzelelement .an (Kopfleiste und Reiter werden + neu gezeichnet, ohne dass Handler neu gebunden werden muessen). statsRequestId verwirft veraltete Antworten, sobald etwas Neueres + angefordert oder der Reiter gewechselt wurde. */ +let statsRequestId = 0 + +function hideChartTip() { + document.querySelectorAll('.an-dot.on').forEach((d) => d.classList.remove('on')) + document.querySelectorAll('.an-tip').forEach((t) => { t.hidden = true }) +} +let tipListening = false + +// Diagramm: Zeigen oder Tippen waehlt den naechsten Tag, die Blase zeigt "Sa., 19.09.: 371 Aufrufe (davor 210)" +function wireChart(stage) { + const svg = stage.querySelector('.an-chart') + const tip = stage.querySelector('.an-tip') + const wrap = svg?.parentElement + const dots = svg ? [...svg.querySelectorAll('.an-dot')] : [] + if (!tip || !wrap || !dots.length) return + if (!tipListening) { + // Antippen ausserhalb schliesst die Blase (auf Touch bleibt sie sonst nach dem Loslassen stehen) + document.addEventListener('pointerdown', (ev) => { if (!ev.target?.closest?.('.an-chart')) hideChartTip() }) + tipListening = true + } + const xs = dots.map((d) => Number(d.getAttribute('cx'))) + const show = (ev) => { + const ctm = svg.getScreenCTM() + if (!ctm) return + const x = new DOMPoint(ev.clientX, ev.clientY).matrixTransform(ctm.inverse()).x + let best = 0 + for (let i = 1; i < xs.length; i++) if (Math.abs(xs[i] - x) < Math.abs(xs[best] - x)) best = i + const dot = dots[best] + dots.forEach((d) => d.classList.toggle('on', d === dot)) + tip.textContent = chartTipText(dot.dataset.day, dot.dataset.pv, dot.dataset.prev) + tip.hidden = false + const box = wrap.getBoundingClientRect() + const at = dot.getBoundingClientRect() + const half = tip.offsetWidth / 2 + tip.style.left = `${Math.min(Math.max(at.left + at.width / 2 - box.left, half), box.width - half)}px` + tip.style.top = `${at.top + at.height / 2 - box.top}px` + tip.classList.toggle('below', at.top - box.top < tip.offsetHeight + 16) + } + svg.addEventListener('pointermove', show) + svg.addEventListener('pointerdown', show) + svg.addEventListener('pointerleave', (ev) => { if (ev.pointerType === 'mouse') hideChartTip() }) + svg.addEventListener('pointercancel', hideChartTip) +} + +function mountCockpit({ an, stage, project, usable, reload }) { + const pid = project.projectId + const state = { stats: null, range: 0, stamp: '', rank: { seen: {}, who: {} } } + const tabNow = () => (TABS.includes(analyseSel.tab) ? analyseSel.tab : 'uebersicht') + const rangeNow = () => (RANGES.includes(analyseSel.range) ? analyseSel.range : 30) + const current = (mine) => stage.isConnected && mine === statsRequestId + + // Kopfleiste neu zeichnen (Zeitraum nur in der Uebersicht, Stand); Fokus auf einem Zeitraum-Knopf bleibt + function paintHead() { + 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, 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 + function paintTabs() { + const tab = tabNow() + an.querySelectorAll('.an-tabbtn').forEach((b) => { + const on = b.dataset.tab === tab + b.classList.toggle('on', on) + b.setAttribute('aria-selected', String(on)) + b.tabIndex = on ? 0 : -1 + }) + stage.setAttribute('aria-labelledby', `an-tab-${tab}`) + } + // Anfrage je Zeitraum: Uebersicht und Heatmap teilen sich eine laufende Anfrage. Sie schreibt nichts in state; uebernommen + // (adopt) wird die Antwort erst nach current(mine), sonst ueberschriebe eine verspaetete den Stand des neueren Zeitraums. + const pending = {} + function fetchStats(range) { + if (!pending[range]) { + const p = request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) + const done = () => { if (pending[range] === p) delete pending[range] } + p.then(done, done) + pending[range] = p + } + return pending[range] + } + function adopt(stats, range) { + state.stats = stats + state.range = range + state.stamp = CLOCK.format(new Date()) + } + + function paintOverview() { + stage.innerHTML = overviewHtml(state.stats) + for (const card of Object.keys(state.rank)) if (state.rank[card].tab) paintRank(card) + wireChart(stage) + } + // Rangliste einer Karte neu zeichnen (nur diese Karte); der Fokus kehrt auf den gewaehlten Umschalter zurueck + function paintRank(card, { focus = false } = {}) { + const old = stage.querySelector(`[data-rank="${card}"]`) + if (!old) return + old.outerHTML = rankCardHtml(card, state.stats, state.rank[card]) + if (focus) stage.querySelector(`[data-rank="${card}"] .an-ltab.on`)?.focus() + } + async function showOverview() { + const mine = ++statsRequestId + const range = rangeNow() + if (state.stats && state.range === range) { + paintOverview() + return + } + stage.innerHTML = skeletonHtml() + let stats + try { + stats = await fetchStats(range) + } catch (err) { + if (!current(mine)) return + stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) + return + } + if (!current(mine)) return + adopt(stats, range) + paintHead() + paintOverview() + } + // Heatmap: Seitenliste aus der zuletzt geladenen Statistik, sonst einmal 30 Tage laden; iframes erst jetzt + async function showHeat() { + const mine = ++statsRequestId + stage.innerHTML = '
    ' + const box = stage.querySelector('#an-heat') + if (!state.stats) { + box.innerHTML = '

    Heatmap

    Lade…

    ' + let stats + try { + stats = await fetchStats(30) + } catch (err) { + if (!current(mine)) return + stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) + return + } + if (!current(mine)) return + adopt(stats, 30) + paintHead() + } + renderAnalyseHeat(box, pid, state.stats.pages || []) + } + function showSettings() { + statsRequestId += 1 + stage.innerHTML = settingsHtml(project) + renderAnalyseOff(stage.querySelector('#an-off'), pid, reload) + } + function showTab() { + paintTabs() + paintHead() + const tab = tabNow() + if (tab === 'heatmap') showHeat() + else if (tab === 'einstellungen') showSettings() + else showOverview() + } + function pickTab(tab) { + if (!TABS.includes(tab) || tab === tabNow()) return + hideChartTip() + analyseSel.tab = tab + showTab() + } + + an.addEventListener('click', (ev) => { + const btn = ev.target?.closest?.('button') + if (!btn || !an.contains(btn)) return + if (btn.dataset.range && btn.closest('.an-seg')) { + // Zeitraum: nur die Uebersicht laedt neu + const range = Number(btn.dataset.range) + if (!RANGES.includes(range) || range === rangeNow()) return + analyseSel.range = range + paintHead() + showOverview() + } else if (btn.dataset.tab && (btn.closest('.an-tabs') || btn.closest('.an-teaser'))) { + pickTab(btn.dataset.tab) + } else if (btn.id === 'an-retry') { + showTab() + } else if (btn.closest('.an-rank')) { + const card = btn.closest('.an-rank').dataset.rank + const rank = state.rank[card] + if (!rank) return + if (btn.dataset.rankTab) Object.assign(rank, { tab: btn.dataset.rankTab, expanded: false }) + else if (btn.hasAttribute('data-rank-more')) rank.expanded = true + else return + paintRank(card, { focus: true }) + } + }) + // Pfeiltasten zwischen den Reitern (Reiter wechselt mit dem Fokus) + an.addEventListener('keydown', (ev) => { + if (!ev.target?.closest?.('.an-tabbtn')) return + const tabs = [...an.querySelectorAll('.an-tabbtn')] + const at = tabs.indexOf(ev.target.closest('.an-tabbtn')) + const next = { ArrowRight: (at + 1) % tabs.length, ArrowLeft: (at + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[ev.key] + if (next === undefined) return + ev.preventDefault() + tabs[next].focus() + pickTab(tabs[next].dataset.tab) + }) + showTab() +} + +/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks. + Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl + per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */ +const heatSel = { big: 'd' } +const HEAT_TIMEOUT_MS = 20000 + +let geraeteCss = null +function loadGeraeteCss() { + if (!geraeteCss) { + geraeteCss = new Promise((resolve) => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = '/geraete.css' + link.onload = link.onerror = () => resolve() + document.head.appendChild(link) + }) + } + return geraeteCss +} + +// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler +let heatListening = false +function onHeatMessage(ev) { + const d = ev.data + if (!d || d.wk !== 'heat') return + for (const fig of document.querySelectorAll('.hs-dev')) { + const frame = fig.querySelector('iframe') + if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue + clearTimeout(fig.heatTimer) + fig.dataset.state = d.state === 'ready' ? 'ready' : 'error' + setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n))) + } +} + +function setHeatCaption(fig, text) { + const cap = fig.querySelector('.hs-cap') + if (cap) cap.textContent = text +} + +// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website +function fitHeatDevice(fig) { + fig.heatResize?.disconnect() + const canvas = fig.querySelector('.dm-canvas') + const host = canvas?.parentElement + if (!host) return + const fit = (width) => { + if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6)) + } + fit(parseFloat(getComputedStyle(host).width) || host.clientWidth) + if (typeof ResizeObserver === 'function') { + fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width)) + fig.heatResize.observe(host) + } +} + +async function loadHeatDevice(fig, projectId, vp, path) { + const run = (fig.heatRun = (fig.heatRun || 0) + 1) + clearTimeout(fig.heatTimer) + fig.heatResize?.disconnect() + fig.dataset.state = 'loading' + fig.innerHTML = `
    ${esc(heatCaption('loading'))}
    ` + let url + try { + ;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } })) + } catch (err) { + if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) + return + } + await loadGeraeteCss() + if (run !== fig.heatRun || !fig.isConnected) return + if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error')) + fig.dataset.origin = new URL(url).origin + fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` }) + fitHeatDevice(fig) + // Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden + let loads = 0 + fig.querySelector('iframe').addEventListener('load', () => { + if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path) + }) + fig.heatTimer = setTimeout(() => { + if (run === fig.heatRun && fig.isConnected && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path) + }, HEAT_TIMEOUT_MS) +} + +// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher +async function showHeatFallback(fig, projectId, vp, path) { + fig.dataset.state = 'timeout' + const cap = fig.querySelector('.hs-cap') + if (!cap) return + cap.textContent = heatCaption('timeout') + try { + const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } }) + // Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt + if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` In neuem Tab öffnen`) + } catch { + /* Text reicht */ + } +} + +function renderAnalyseHeat(box, projectId, pages) { + if (!box) return + if (!pages.length) { + box.innerHTML = '

    Heatmap

    Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

    ' + return + } + if (!heatListening) { + window.addEventListener('message', onHeatMessage) + heatListening = true + } + const big = heatSel.big === 't' ? 't' : 'd' + box.innerHTML = ` +

    Heatmap

    +

    So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.

    +
    + +
    ${['d', 't'].map((vp) => ``).join('')}
    +
    +
    +
    +
    +
    ` + const stage = box.querySelector('.hs-stage') + const bigFig = box.querySelector('.hs-dev--big') + const phoneFig = box.querySelector('.hs-dev--phone') + const page = () => box.querySelector('#an-heat-page').value || '/' + const show = (fig, vp) => { + fig.heatReloads = 0 + loadHeatDevice(fig, projectId, vp, page()) + } + box.querySelector('#an-heat-page').addEventListener('change', () => { + show(bigFig, heatSel.big) + show(phoneFig, 'm') + }) + box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => { + if (b.dataset.vp === heatSel.big) return + heatSel.big = b.dataset.vp + box.querySelectorAll('.hs-switch button').forEach((x) => { + const on = x.dataset.vp === heatSel.big + x.classList.toggle('on', on) + x.setAttribute('aria-pressed', String(on)) + }) + stage.dataset.big = heatSel.big + show(bigFig, heatSel.big) + })) + heatSel.big = big + // Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite) + const start = () => { + show(bigFig, heatSel.big) + show(phoneFig, 'm') + } + if (typeof IntersectionObserver !== 'function') return start() + const io = new IntersectionObserver((entries) => { + if (!entries.some((e) => e.isIntersecting)) return + io.disconnect() + start() + }, { rootMargin: '300px 0px' }) + io.observe(stage) +} + +// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog +export function renderAnalyseOff(box, projectId, reload) { + box.innerHTML = ` +

    Analyse ausschalten

    +

    Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

    + +
    ` + const actions = box.querySelector('#an-off-actions') + const error = box.querySelector('#an-off-error') + box.querySelector('#an-off-ask').addEventListener('click', () => { + actions.innerHTML = 'Wirklich ausschalten? ' + actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload)) + actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => { + ev.currentTarget.disabled = true + try { + await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) + box.innerHTML = ` +

    Analyse ausgeschaltet

    +

    Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

    +
    ` + box.querySelector('#an-off-ok').addEventListener('click', () => reload()) + } catch (err) { + renderAnalyseOff(box, projectId, reload) + const e2 = box.querySelector('#an-off-error') + e2.textContent = err.message + e2.classList.remove('hidden') + } + }) + }) +} diff --git a/public/app.js b/public/app.js index ce23af1..02c9075 100644 --- a/public/app.js +++ b/public/app.js @@ -1917,7 +1917,7 @@ const viewLoaders = { chat: initChat } const viewRefreshers = { funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))), news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))), - analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))), + analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))), termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))), } const loadedViews = new Set() diff --git a/public/dashboard.html b/public/dashboard.html index 6b48c97..e697b2e 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -22,6 +22,7 @@ + @@ -177,7 +178,7 @@

    Lade…

    - +

    Analyse

    diff --git a/public/fn-util.js b/public/fn-util.js new file mode 100644 index 0000000..5b1ef11 --- /dev/null +++ b/public/fn-util.js @@ -0,0 +1,22 @@ +/* Gemeinsame Hilfen der Funktions-Module (funktionen.js, analyse.js): Escaping fuer Kundentexte, URL-Pruefung, API-Aufruf. */ + +export const esc = (value) => String(value ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +export const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '') + +export async function request(path, { method = 'GET', body, raw } = {}) { + const response = await fetch(path, { + method, + credentials: 'same-origin', + headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {}, + body: raw || (body ? JSON.stringify(body) : undefined), + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`)) + err.status = response.status + if (data.field) err.field = data.field + throw err + } + return data +} diff --git a/public/funktionen.css b/public/funktionen.css index 0cbfd4b..d3078ec 100644 --- a/public/funktionen.css +++ b/public/funktionen.css @@ -137,82 +137,3 @@ .fn-tiles { grid-template-columns: minmax(0, 1fr); } .fn-facts dt { min-width: 0; flex: 1 0 100%; } } - -/* ------------------------------------------------------------- Analyse --- */ -#view-analyse .kb-panel { max-width: none; } -#view-analyse .an-panel { max-width: 720px; } -#view-analyse .fn-project { max-width: 640px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; } -#view-analyse .kb-panel + .kb-panel { margin-top: 16px; } -.an-panel p { margin: 0 0 12px; line-height: 1.55; } -.an-h4 { margin: 18px 0 8px; font-size: 14px; } -.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } -.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: transparent; cursor: pointer; } -.an-presets { display: flex; flex-wrap: wrap; gap: 8px; } -.an-preset { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--kb-border-strong); cursor: pointer; padding: 0; } -.an-hint { margin: 10px 0 0; font-size: 13px; color: #fcd34d; } -/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */ -.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } -.an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; } -.an-bn-link { text-decoration: underline; } -.an-bn-row { display: flex; gap: 10px; flex: none; } -.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 150px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } - -.an-range { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } -.an-tab { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; } -.an-tab.active { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); } -.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } -.an-kpi { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); min-width: 0; } -.an-kpi span { display: block; font-size: 13px; color: var(--kb-muted); } -.an-kpi b { display: block; margin-top: 4px; font-size: 26px; font-variant-numeric: tabular-nums; } -.an-chart-box { margin-bottom: 16px; } -.an-chart { display: block; width: 100%; height: auto; } -.an-grid { stroke: var(--kb-border-strong); stroke-width: 1; } -.an-axis { fill: var(--kb-muted); font-size: 11px; } -.an-line { fill: none; stroke: var(--kb-lilac); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; } -.an-area { fill: var(--kb-purple-soft); stroke: none; } -.an-dot { fill: var(--kb-lilac); } -.an-grid-lists { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; align-items: start; } -#view-analyse .an-grid-lists .kb-panel { margin-top: 0; } -.an-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } -.an-row-top { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; } -.an-row-label { min-width: 0; overflow-wrap: anywhere; } -.an-row-top b { font-variant-numeric: tabular-nums; } -.an-bar { height: 6px; margin-top: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.07); overflow: hidden; } -.an-bar i { display: block; height: 100%; border-radius: 3px; background: var(--kb-purple); } -.an-note, .an-credit { margin: 16px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--kb-muted); } -.an-credit { font-size: 11.5px; color: var(--kb-faint); } -.an-credit a { color: inherit; } -.an-heat { margin-top: 16px; } -.an-heat p, .an-off p { margin: 0 0 12px; line-height: 1.55; } -.an-heat-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; } -.an-heat-row label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; flex: 1 1 200px; min-width: 0; } -.an-heat-row select { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; min-width: 0; } -.an-heat-row select option { color: #111; } -/* Heatmap-Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */ -.an-heat .hs-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; } -.an-heat .hs-head h3 { margin: 0; } -.hs-switch { display: flex; gap: 8px; } -.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; } -.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; } -.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; } -.hs-frame { width: 100%; } -.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--kb-muted); text-align: center; } -.hs-dev .dm-canvas { background: #fff; } -.hs-site { position: absolute; left: 0; background: #fff; } -.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; } -@media (max-width: 640px) { - .hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; } - .hs-dev--phone .hs-frame { width: 62%; } -} -.an-off { margin-top: 16px; } -.an-ghost { padding: 9px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; } -.an-ghost:hover { background: rgba(255, 255, 255, 0.07); } -.an-danger { color: #fca5a5; } - -@media (max-width: 880px) { - .an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .an-grid-lists { grid-template-columns: minmax(0, 1fr); } - .an-bn { display: block; margin: 12px 0; padding: 16px; } - .an-bn-row { flex-direction: column; margin-top: 12px; } - .an-bn-btn { width: 100%; } -} diff --git a/public/funktionen.js b/public/funktionen.js index a8e6889..392dce0 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -3,8 +3,8 @@ Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css. Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ -import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' -import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' +import { esc, safeUrl, request } from '/fn-util.js' +import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.js' import { ZU_VERTRAEGE } from './vertraege-link.js' const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt @@ -28,10 +28,6 @@ const TYPES = [ ['combo', 'Foto + Text', 'beides', ''], ] -const esc = (value) => String(value ?? '') - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') -const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '') - function fmtDay(value) { const d = new Date(value) if (!value || Number.isNaN(d.getTime())) return '' @@ -44,23 +40,6 @@ function berlinIsoDay(offsetDays = 0) { return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) } -async function request(path, { method = 'GET', body, raw } = {}) { - const response = await fetch(path, { - method, - credentials: 'same-origin', - headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {}, - body: raw || (body ? JSON.stringify(body) : undefined), - }) - const data = await response.json().catch(() => ({})) - if (!response.ok) { - const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`)) - err.status = response.status - if (data.field) err.field = data.field - throw err - } - return data -} - /* ------------------------------------------------------------ Zustand --- */ // Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten const selection = { projectId: '' } @@ -120,15 +99,9 @@ function newsTile(projects, price = PRICE) { const ZU_VERTRAEGE_LINK = `` /* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ -const analyseOf = (project) => project.analyse || { comingSoon: true } -const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.' const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.' -function pickAnalyseProject(projects) { - return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0] -} - function analyseTile(projects) { const target = pickAnalyseProject(projects) const a = target && analyseOf(target) @@ -656,374 +629,3 @@ function renderEditor(stage, project) { sync() return loadList() } - -/* -------------------------------------------------------------- Analyse --- */ -const analyseSel = { projectId: '', range: 30 } -const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] -const HEX_RE = /^#[0-9a-f]{6}$/i -const nf = new Intl.NumberFormat('de-DE') -const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.' - -export async function initAnalyse(root, { projectId } = {}) { - if (!root) return - if (projectId) analyseSel.projectId = projectId - const { projects = [] } = await request('/api/funktionen') - const usable = projects.filter((p) => !analyseOf(p).comingSoon) - if (!usable.length) { - root.innerHTML = '
    Die Analyse ist für deine Websites noch nicht freigeschaltet.
    ' - return - } - const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) - analyseSel.projectId = project.projectId - root.innerHTML = ` - ${usable.length > 1 ? ` -
    - - -
    ` : ''} -
    ` - root.querySelector('#an-project')?.addEventListener('change', (ev) => { - analyseSel.projectId = ev.target.value - initAnalyse(root).catch((err) => { root.innerHTML = `

    ${esc(err.message)}

    ` }) - }) - const stage = root.querySelector('#an-stage') - const a = analyseOf(project) - const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `

    ${esc(err.message)}

    ` }) - if (a.state === 'active') renderAnalyseStats(stage, project, reload) - else if (a.state === 'disabled') stage.innerHTML = '

    Analyse ist abgeschaltet

    Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

    ' - else if (a.state === 'unknown') stage.innerHTML = '

    Die Daten konnten gerade nicht geladen werden.

    ' - else if (!a.eligible) stage.innerHTML = `

    Analyse nicht verfügbar

    ${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ${ZU_VERTRAEGE_LINK}` : ''}

    ` - else renderAnalyseActivation(stage, project, reload) -} - -function bannerPreview(color) { - const c = bannerColor(color) - const btn = `style="background:${c};border-color:${c};color:#fff"` - return ` - ` -} - -const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' - -// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt -function controllerBlock(controller) { - if (controller.ok !== true) return `` - return `

    Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

    ${controllerLines(controller).map(esc).join(' · ')}

    Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

    ` -} - -export function renderAnalyseActivation(stage, project, reload) { - const a = analyseOf(project) - const legal = a.legal || {} - const controller = a.controller || { ok: false } - let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' - stage.innerHTML = ` -
    -

    Analyse für ${esc(project.projectName)} einschalten

    -

    Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

    -

    Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.

    -

    Farbe des Banners

    -
    - -
    ${PRESET_COLORS.map((c) => ``).join('')}
    -
    - -
    - ${controllerBlock(controller)} - - -
    -
    ` - const input = stage.querySelector('#an-color') - const hint = stage.querySelector('#an-hint') - const paint = () => { - stage.querySelector('#an-preview').innerHTML = bannerPreview(color) - hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) - } - paint() - input.addEventListener('input', () => { color = input.value; paint() }) - stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) - const box = stage.querySelector('#an-avv') - const button = stage.querySelector('#an-on') - const error = stage.querySelector('#an-error') - box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) - button.addEventListener('click', async () => { - if (!box.checked || controller.ok !== true) return - button.disabled = true - error.classList.add('hidden') - try { - await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) - reload() - } catch (err) { - error.textContent = err.message - error.classList.remove('hidden') - button.disabled = !box.checked || controller.ok !== true - } - }) -} - -function lineChart(series) { - const m = chartModel(series) - if (!m.points.length) return '

    Noch keine Daten.

    ' - const total = m.points.reduce((sum, p) => sum + p.pv, 0) - const first = m.points[0].day - const last = m.points[m.points.length - 1].day - return ` - - Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))} - ${m.yTicks.map((t) => `${esc(t.label)}`).join('')} - ${m.xLabels.map((l) => `${esc(l.label)}`).join('')} - - - ${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))}`).join('') : ''} - ` -} - -function barList(title, items, labelOf, { html = false } = {}) { - const rows = barRows(items, labelOf) - return ` -
    -

    ${esc(title)}

    - ${rows.length ? `
      ${rows.map((r) => ` -
    • ${html ? r.label : esc(r.label)}${esc(nf.format(r.count))}
    • `).join('')}
    ` : '

    Noch keine Daten.

    '} -
    ` -} - -const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${esc(countryName(key))}` - -let statsRequestId = 0 -async function renderAnalyseStats(stage, project, reload) { - const myRequest = ++statsRequestId - const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30 - const pid = project.projectId - stage.innerHTML = ` -
    ${[7, 30, 90].map((r) => ``).join('')}
    -

    Lade…

    -
    -
    -

    Länderdaten: IP-Geolocation by DB-IP

    ` - stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) })) - renderAnalyseOff(stage.querySelector('#an-off'), pid, reload) - - const data = stage.querySelector('#an-data') - let stats - try { - stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) - } catch (err) { - if (!stage.isConnected || myRequest !== statsRequestId) return - data.innerHTML = `` - return - } - if (!stage.isConnected || myRequest !== statsRequestId) return - const t = stats.totals || {} - const rate = Math.round((Number(t.consentRate) || 0) * 100) - const kpi = (label, value) => `
    ${label}${esc(value)}
    ` - const b = stats.banner || {} - data.innerHTML = ` -
    ${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}
    -

    Seitenaufrufe pro Tag

    ${lineChart(stats.series)}
    -
    - ${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')} - ${barList('Herkunft', stats.refs, refLabel)} - ${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })} - ${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')} - ${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)} - ${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))} -
    -

    ${esc(KPI_NOTE)}

    ` - renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) -} - -/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks. - Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl - per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */ -const heatSel = { big: 'd' } -const HEAT_TIMEOUT_MS = 20000 - -let geraeteCss = null -function loadGeraeteCss() { - if (!geraeteCss) { - geraeteCss = new Promise((resolve) => { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = '/geraete.css' - link.onload = link.onerror = () => resolve() - document.head.appendChild(link) - }) - } - return geraeteCss -} - -// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler -let heatListening = false -function onHeatMessage(ev) { - const d = ev.data - if (!d || d.wk !== 'heat') return - for (const fig of document.querySelectorAll('.hs-dev')) { - const frame = fig.querySelector('iframe') - if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue - clearTimeout(fig.heatTimer) - fig.dataset.state = d.state === 'ready' ? 'ready' : 'error' - setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n))) - } -} - -function setHeatCaption(fig, text) { - const cap = fig.querySelector('.hs-cap') - if (cap) cap.textContent = text -} - -// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website -function fitHeatDevice(fig) { - fig.heatResize?.disconnect() - const canvas = fig.querySelector('.dm-canvas') - const host = canvas?.parentElement - if (!host) return - const fit = (width) => { - if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6)) - } - fit(parseFloat(getComputedStyle(host).width) || host.clientWidth) - if (typeof ResizeObserver === 'function') { - fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width)) - fig.heatResize.observe(host) - } -} - -async function loadHeatDevice(fig, projectId, vp, path) { - const run = (fig.heatRun = (fig.heatRun || 0) + 1) - clearTimeout(fig.heatTimer) - fig.heatResize?.disconnect() - fig.dataset.state = 'loading' - fig.innerHTML = `
    ${esc(heatCaption('loading'))}
    ` - let url - try { - ;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } })) - } catch (err) { - if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) - return - } - await loadGeraeteCss() - if (run !== fig.heatRun || !fig.isConnected) return - if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error')) - fig.dataset.origin = new URL(url).origin - fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` }) - fitHeatDevice(fig) - // Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden - let loads = 0 - fig.querySelector('iframe').addEventListener('load', () => { - if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path) - }) - fig.heatTimer = setTimeout(() => { - if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path) - }, HEAT_TIMEOUT_MS) -} - -// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher -async function showHeatFallback(fig, projectId, vp, path) { - fig.dataset.state = 'timeout' - const cap = fig.querySelector('.hs-cap') - if (!cap) return - cap.textContent = heatCaption('timeout') - try { - const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } }) - // Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt - if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` In neuem Tab öffnen`) - } catch { - /* Text reicht */ - } -} - -function renderAnalyseHeat(box, projectId, pages) { - if (!box) return - if (!pages.length) { - box.innerHTML = '

    Heatmap

    Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

    ' - return - } - if (!heatListening) { - window.addEventListener('message', onHeatMessage) - heatListening = true - } - const big = heatSel.big === 't' ? 't' : 'd' - box.innerHTML = ` -
    -

    Heatmap

    -
    ${['d', 't'].map((vp) => ``).join('')}
    -
    -

    So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.

    -
    - -
    -
    -
    -
    -
    ` - const stage = box.querySelector('.hs-stage') - const bigFig = box.querySelector('.hs-dev--big') - const phoneFig = box.querySelector('.hs-dev--phone') - const page = () => box.querySelector('#an-heat-page').value || '/' - const show = (fig, vp) => { - fig.heatReloads = 0 - loadHeatDevice(fig, projectId, vp, page()) - } - box.querySelector('#an-heat-page').addEventListener('change', () => { - show(bigFig, heatSel.big) - show(phoneFig, 'm') - }) - box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => { - if (b.dataset.vp === heatSel.big) return - heatSel.big = b.dataset.vp - box.querySelectorAll('.hs-switch button').forEach((x) => { - const on = x.dataset.vp === heatSel.big - x.classList.toggle('active', on) - x.setAttribute('aria-pressed', String(on)) - }) - stage.dataset.big = heatSel.big - show(bigFig, heatSel.big) - })) - heatSel.big = big - // Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite) - const start = () => { - show(bigFig, heatSel.big) - show(phoneFig, 'm') - } - if (typeof IntersectionObserver !== 'function') return start() - const io = new IntersectionObserver((entries) => { - if (!entries.some((e) => e.isIntersecting)) return - io.disconnect() - start() - }, { rootMargin: '300px 0px' }) - io.observe(stage) -} - -// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog -export function renderAnalyseOff(box, projectId, reload) { - box.innerHTML = ` -

    Analyse ausschalten

    -

    Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

    - -
    ` - const actions = box.querySelector('#an-off-actions') - const error = box.querySelector('#an-off-error') - box.querySelector('#an-off-ask').addEventListener('click', () => { - actions.innerHTML = 'Wirklich ausschalten? ' - actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload)) - actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => { - ev.currentTarget.disabled = true - try { - await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) - box.innerHTML = ` -

    Analyse ausgeschaltet

    -

    Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

    -
    ` - box.querySelector('#an-off-ok').addEventListener('click', () => reload()) - } catch (err) { - renderAnalyseOff(box, projectId, reload) - const e2 = box.querySelector('#an-off-error') - e2.textContent = err.message - e2.classList.remove('hidden') - } - }) - }) -} diff --git a/public/vertraege-link.js b/public/vertraege-link.js index b258dd0..adda078 100644 --- a/public/vertraege-link.js +++ b/public/vertraege-link.js @@ -1,5 +1,5 @@ /* Der Link „Zu Verträge“ in den Hinweisen ohne Wartungs-Abo (Grund kein_abo): die einzige Quelle für Text, Ziel und Klasse. Gelesen von - funktionen.js (Kachel Analyse, Analyse-Seite, Kachel Terminplaner; baut daraus HTML) und von termine.js (Hinweis in der Ansicht #termine; - baut daraus ein Element). Reine Daten ohne Import: beide laden das Modul relativ, funktionen.js behält seine zwei absoluten Importe. + funktionen.js (Kachel Analyse, Kachel Terminplaner; baut daraus HTML), analyse.js (Analyse-Seite; baut daraus HTML) und termine.js (Hinweis + in der Ansicht #termine; baut daraus ein Element). Reine Daten ohne Import: alle laden das Modul relativ. Die Hinweistexte selbst stehen nicht hier: sie kommen vom Server (aboMeldung in server/services/featureOrder.js). */ export const ZU_VERTRAEGE = Object.freeze({ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' }) diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js index ba7df49..b9382a4 100644 --- a/server/middleware/appwriteStaff.js +++ b/server/middleware/appwriteStaff.js @@ -1,6 +1,20 @@ import { config } from '../config.js' +import { ADMIN_LABEL, STAFF_LABELS } from '../services/staffLabels.js' -export async function requireAppwriteStaff(req, res, next) { +/** + * Prueft das Appwrite-JWT aus dem Ticketsystem und verlangt eines der Labels `allowed`. + * requireAppwriteStaff: nur "admin" (Kunden, Mitarbeiter, Finanzen ...). + * requireAppwriteTeam: jeder Mitarbeiter ("mitarbeiter" oder "admin") - Projekt-Aktionen + * (Anlegen, Zuordnen, Archivieren, Preview, Analyse, Gitea-Import), Entscheidung 2026-10-04. + */ +function requireAppwriteLabels(allowed, deniedMessage) { + return (req, res, next) => checkAppwriteJwt(req, res, next, allowed, deniedMessage) +} + +export const requireAppwriteStaff = requireAppwriteLabels([ADMIN_LABEL], 'Admin-Berechtigung erforderlich') +export const requireAppwriteTeam = requireAppwriteLabels(STAFF_LABELS, 'Nur für Mitarbeiter') + +async function checkAppwriteJwt(req, res, next, allowed, deniedMessage) { const authHeader = req.get('Authorization') || '' const match = authHeader.match(/^Bearer\s+(.+)$/i) if (!match) { @@ -29,11 +43,11 @@ export async function requireAppwriteStaff(req, res, next) { } if (!response.ok) { - return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' }) + return res.status(401).json({ error: data?.message || 'Ungültiges JWT' }) } - if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { - return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' }) + if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) { + return res.status(403).json({ error: deniedMessage }) } req.appwriteUser = data diff --git a/server/middleware/cors.js b/server/middleware/cors.js index b5562c2..deab6d5 100644 --- a/server/middleware/cors.js +++ b/server/middleware/cors.js @@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) { if (origin && allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS) - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') if (req.method === 'OPTIONS') { return res.sendStatus(204) } diff --git a/server/routes/admin/analyse.js b/server/routes/admin/analyse.js index f9f2e47..b1cec8e 100644 --- a/server/routes/admin/analyse.js +++ b/server/routes/admin/analyse.js @@ -2,12 +2,13 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js' import { getFeatureDoc } from '../../services/siteFeatures.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) function sendError(res, err) { const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500 diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index e493613..176de00 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -121,7 +121,7 @@ async function unterlagenAlsAnhang() { // je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut. async function adminView(customerId) { const docs = await listCustomerFeatureDocs(customerId) - const projects = await listCustomerFeatures(customerId, { docs }) + const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true }) const abo = aboMemo() for (const p of projects) { for (const key of FEATURE_KEYS) { diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js index efecce8..b84bb57 100644 --- a/server/routes/admin/gitea.js +++ b/server/routes/admin/gitea.js @@ -1,10 +1,13 @@ import { Router } from 'express' +import { config } from '../../config.js' +import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/sync-repos', async (_req, res) => { try { @@ -35,6 +38,12 @@ router.get('/repo-readme', async (req, res) => { } try { + // Nur Repos, die ein Projekt sind - der System-Schluessel sieht sonst jedes (auch private) Repo + const known = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + if (!known[0]) return res.status(404).json({ error: 'Projekt nicht gefunden' }) const readme = await fetchRepoReadme(repoFullName) if (!readme) { return res.json({ ok: true, found: false, repoFullName }) diff --git a/server/routes/admin/preview.js b/server/routes/admin/preview.js index 3f135f5..ded1461 100644 --- a/server/routes/admin/preview.js +++ b/server/routes/admin/preview.js @@ -2,7 +2,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument, updateDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js' import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js' import * as runnerClient from '../../services/previewRunnerClient.js' @@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js' import { repoHasDevScript } from '../../services/previewDevScript.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const BUSY = 'Server ausgelastet, versuche es gleich noch mal' const MODES = ['auto', 'build', 'dev', 'off'] diff --git a/server/routes/admin/services.js b/server/routes/admin/services.js index b9dc194..21bdcc7 100644 --- a/server/routes/admin/services.js +++ b/server/routes/admin/services.js @@ -2,11 +2,12 @@ import { Router } from 'express' import { config } from '../../config.js' import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { servicesCatalog } from '../../services/projectKinds.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const CHUNK = 100 diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index a22bb46..1930d35 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -6,14 +6,15 @@ import { } from '../../services/giteaAdmin.js' import { assignProject, reconcileProject } from '../../services/projectAssignment.js' import { completeAssignment } from '../../services/projectKindAssign.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/create-from-template', async (req, res) => { - const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {} + const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {} if (!repoName && !displayName) { return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' }) @@ -27,6 +28,8 @@ router.post('/create-from-template', async (req, res) => { customerId, ticketId, owner, + kind, + staffName: req.appwriteUser?.name || req.appwriteUser?.email || '', }) return res.status(201).json({ ok: true, ...result }) } catch (err) { @@ -38,6 +41,10 @@ router.post('/create-from-template', async (req, res) => { // Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite router.patch('/:id', async (req, res) => { const { projectName, subdomain, projectType } = req.body || {} + // Vorlagen bleiben Admin-Sache (im Ticketsystem nur fuer Admins sichtbar) + if (projectType === 'template' && !req.appwriteUser?.labels?.includes('admin')) { + return res.status(403).json({ error: 'Nur Admins können Vorlagen anlegen.' }) + } try { const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType }) return res.json({ ok: true, ...result }) diff --git a/server/routes/auth.js b/server/routes/auth.js index 578dcf4..6ab0708 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, queries) const project = projects[0] - if (!project) { + // Archiviert: fuer Kunden wie kein Projekt (das Portal blendet es aus) + if (!project || (project.archived && !admin)) { return res.json({ allowed: false, reason: 'no_project' }) } if (!isPreviewProjectReady(project)) { diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 5533d57..9a9ce51 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -76,6 +76,7 @@ async function analyseView(customerId, projectId) { avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller, + activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null, } } @@ -258,7 +259,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => { return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) } const customerId = getSessionCustomerId(req) - await assertOwnProject(customerId, projectId) + await assertOwnProject(customerId, projectId, { forNewOrder: true }) if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') { throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') } @@ -342,6 +343,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => { return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' }) } const { projectId, accentColor, consent } = req.body || {} + await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true }) const { doc } = await activateAnalyse({ customerId: getSessionCustomerId(req), projectId: analyseProjectId(projectId), diff --git a/server/routes/projects.js b/server/routes/projects.js index 093d24a..70697e6 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -14,7 +14,8 @@ const router = Router() async function loadOwnProject(req, res, next) { const customerId = getSessionCustomerId(req) const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null) - if (!project || !customerId || project.customerId !== customerId) { + // Archivierte Projekte sieht der Kunde nicht mehr (wie in der Liste) + if (!project || !customerId || project.customerId !== customerId || project.archived) { return res.status(404).json({ error: 'Projekt nicht gefunden' }) } req.project = project @@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), + Query.limit(500), // ohne Limit liefert Appwrite nur 25 ]) - const sanitized = projects.map((p) => ({ + // Archivierte Projekte (Vorschau entfernt) blendet das Portal aus (Entscheidung 2026-10-04) + const sanitized = projects.filter((p) => !p.archived).map((p) => ({ id: p.$id, projectName: p.projectName || '', subdomain: p.subdomain || '', diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index dee6506..fa9a251 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -1,16 +1,17 @@ import crypto from 'node:crypto' import { Router } from 'express' import { config } from '../../config.js' -import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js' +import { upsertWebsiteProjectByRepo, getDocument, listDocuments, Query } from '../../services/appwriteAdmin.js' import { fetchPreviewConfig, defaultPreviewConfig, runDeploy, + foreignSubdomainBlock, deployKeepsStatus, buildPreviewUrl, } from '../../services/previewDeploy.js' import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js' -import { createGitPushWorksheet } from '../../services/gitWorksheet.js' +import { createGitPushWorksheet, isSystemPusher } from '../../services/gitWorksheet.js' import { sendPushTimeMail } from '../../services/pushTimeMail.js' import { ensureProjectTicket } from '../../services/ticketAdmin.js' import { invalidateProjectCache } from '../../services/projectInsights.js' @@ -107,18 +108,24 @@ router.post('/gitea', async (req, res) => { } } const previewConfig = previewConfigFile || defaultPreviewConfig(name) - const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile) + const wantsPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile) + // Kopiertes Repo mit der .webklar-preview.json eines anderen Projekts: dessen Subdomain weder + // beliefern noch uebernehmen - das Projekt bleibt ohne Preview, bis WOMS ihm eine eigene gibt. + const blocked = wantsPreview + ? await foreignSubdomainBlock(name, previewConfig.subdomain || name.split('/').pop()) + : null + const hasPreview = wantsPreview && !blocked // Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen - // Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website. const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : '' const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' - let deployResult = { deployed: false, skipped: true } + let deployResult = blocked || { deployed: false, skipped: true } const configBranch = previewConfig.branch || 'main' if (hasPreview && previewConfig.enabled !== false && branch === configBranch) { try { - deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha }) + deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha, subdomainChecked: true }) } catch (deployErr) { console.error('[webhook] deploy failed:', deployErr.message) deployResult = { deployed: false, error: deployErr.message } @@ -141,7 +148,8 @@ router.post('/gitea', async (req, res) => { const payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase() const projectPayload = { - projectName: previewConfig.displayName || (payloadNameStillCurrent ? repo.name : '') || subdomain, + // der Anzeigename einer fremden Config gehoert ebenfalls dem Original + projectName: (blocked ? '' : previewConfig.displayName) || (payloadNameStillCurrent ? repo.name : '') || subdomain, templateName: previewConfig.templateName || config.gitea.templateRepo, giteaRepoUrl: payloadNameStillCurrent ? repo.html_url || `${config.gitea.baseUrl}/${current}` @@ -161,6 +169,20 @@ router.post('/gitea', async (req, res) => { } else if (hasPreview) { projectPayload.status = 'pending' projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending' + } else if (blocked) { + // eine schon vergebene eigene Preview bleibt, wie sie ist - sonst Projekt ohne Preview; + // ein vorhandener Projektname auch (der Repo-Name waere nur der Ersatz fuer neue Projekte) + const existing = (await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', current), + Query.limit(1), + ]))[0] + if (!existing?.subdomain) projectPayload.status = 'synced' + else { + // sonst stuende im WOMS weiter der alte Bau als aktuell da (Fehlertext nur bei 'failed') + projectPayload.previewState = 'failed' + projectPayload.previewError = `Neuer Stand nicht veröffentlicht: In .webklar-preview.json steht die Subdomain „${blocked.subdomain}“, die gehört ${blocked.owner}. Projekt im WOMS bearbeiten und speichern, dann stimmt die Datei wieder.`.slice(0, 500) + } + if (existing?.projectName) delete projectPayload.projectName } else { projectPayload.status = 'synced' } @@ -177,7 +199,10 @@ router.post('/gitea', async (req, res) => { // (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht // dieselbe ticketId-Aufloesung doppelt anstoesst. let gitWorksheet = null - if (commitSha && commitSha !== '0000000000000000000000000000000000000000') { + // System-Commits (z. B. .webklar-preview.json beim Anlegen aus dem Ticketsystem) bekommen weder + // Arbeitsblatt noch Auto-Ticket - sonst entstand beim Anlegen mit Kunde ein zusaetzliches + // Ticket "Website " neben dem, das im Zuordnen-Assistenten gewaehlt wird. + if (commitSha && commitSha !== '0000000000000000000000000000000000000000' && !isSystemPusher(pusher)) { gitWorksheet = await withLocks( ['project:' + project.$id], async () => { diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index 8a90d10..e420aa5 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -317,19 +317,32 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) { } const now = new Date().toISOString() - return adminFetch(collectionPath(config.collections.websiteProjects), { - method: 'POST', - body: { - ...payload, - customerId: payload.customerId ?? '', - ticketId: payload.ticketId ?? '', - status: payload.status ?? 'pending', - provisioningStatus: payload.provisioningStatus ?? 'pending', - projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, - createdAt: now, - documentId: ID.unique(), - }, - }) + try { + return await adminFetch(collectionPath(config.collections.websiteProjects), { + method: 'POST', + body: { + ...payload, + customerId: payload.customerId ?? '', + ticketId: payload.ticketId ?? '', + status: payload.status ?? 'pending', + provisioningStatus: payload.provisioningStatus ?? 'pending', + projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, + createdAt: now, + documentId: ID.unique(), + }, + }) + } catch (err) { + // Ein anderer Schreiber ohne gemeinsame Sperre hat das Dokument zwischen Suche und Anlage + // angelegt: der Unique-Index auf repoFullName meldet 409. Dann das vorhandene Dokument + // aktualisieren, statt den Fehler bis in die Oberflaeche zu reichen. + if (err.status !== 409) throw err + const raced = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + if (!raced[0]) throw err + return updateDocument(config.collections.websiteProjects, raced[0].$id, buildProjectPayload(raced[0], data)) + } } export async function verifyDatabaseAccess() { diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index eb80902..5a2aae7 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -198,7 +198,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { portalPassword = normalizedPassword } } else if (normalizedPassword) { - const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern') + const error = new Error('Portal-Login fehlt – bitte E-Mail setzen und Passwort erneut speichern') error.status = 400 throw error } diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 878333b..c0c75a4 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -5,6 +5,7 @@ import { Query, getDocument, updateDocument, + deleteDocument, } from './appwriteAdmin.js' import { runDeploy, @@ -17,7 +18,8 @@ import { import { giteaFetch } from './giteaClient.js' import { slugify } from './slug.js' import { withLocks } from './keyedLock.js' -import { repoLockKey } from './repoMove.js' +import { repoLockKey, isUnlinkedProject } from './repoMove.js' +import { isKind } from './projectKinds.js' // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte // Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht @@ -49,12 +51,16 @@ export async function listAllAccessibleRepos() { /** * Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer - * jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) - - * ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem + * jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte + * als archiviertes Projekt) - ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Bei + * vorhandenen Dokumenten wird nur der Archiv-Stand aus Gitea gespiegelt (Repo dort direkt + * archiviert oder reaktiviert). Wird vom Projekt-Dialog bei jedem * Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert * parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die * innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere * gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung). + * Ein Fehler bei einem Repo bricht den Lauf nicht ab (sonst fehlten alle Repos danach). + * `createdRepos` enthaelt nur neue, nicht archivierte Repos (nur sie bekommen Zuordnungs-Mails). */ export async function importNewRepos() { return withLocks(['import'], async () => { @@ -64,39 +70,75 @@ export async function importNewRepos() { ]) // Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling). - const existingNames = new Set( - docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean) - ) - - let created = 0 - const createdRepos = [] - for (const repo of repos) { - const fullName = repo.full_name - if (!fullName || repo.archived) continue - if (existingNames.has(fullName.toLowerCase())) continue - - await withLocks([repoLockKey(fullName)], async () => { - const recheck = await listDocuments(config.collections.websiteProjects, [ - Query.equal('repoFullName', fullName), - Query.limit(1), - ]) - if (recheck[0]) return - - const doc = await upsertWebsiteProjectByRepo(fullName, { - projectName: repo.description || repo.name || fullName.split('/').pop(), - giteaRepoUrl: repo.html_url, - giteaRepoName: repo.name, - repoFullName: fullName, - archived: false, - status: 'synced', - giteaSyncedAt: new Date().toISOString(), - }) - created += 1 - createdRepos.push({ projectId: doc.$id, repo }) - }) + const docsByName = new Map() + for (const d of docs) { + const key = String(d.repoFullName || '').toLowerCase() + if (key && !docsByName.has(key)) docsByName.set(key, d) } - return { created, createdRepos } + let created = 0 + let archivedImported = 0 + let archiveSynced = 0 + const createdRepos = [] + const failed = [] + for (const repo of repos) { + const fullName = repo.full_name + if (!fullName) continue + const repoArchived = Boolean(repo.archived) + + try { + const existing = docsByName.get(fullName.toLowerCase()) + if (existing) { + if (Boolean(existing.archived) === repoArchived) continue + await withLocks([repoLockKey(fullName)], async () => { + // Unter der Sperre frisch lesen (Dokument UND Gitea): die Repo-Liste oben kann aelter + // sein als ein gerade laufendes Archivieren/Reaktivieren im Ticketsystem. + const fresh = await getDocument(config.collections.websiteProjects, existing.$id) + const live = await giteaFetch(`/repos/${fullName}`) + const liveArchived = Boolean(live?.archived) + if (!fresh || Boolean(fresh.archived) === liveArchived) return + await updateDocument(config.collections.websiteProjects, fresh.$id, { + archived: liveArchived, + ...(liveArchived + ? { status: 'archived' } + : fresh.status === 'archived' ? { status: 'synced' } : {}), + updatedAt: new Date().toISOString(), + }) + archiveSynced += 1 + }, { waitMs: 10000 }) + continue + } + + await withLocks([repoLockKey(fullName)], async () => { + const recheck = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', fullName), + Query.limit(1), + ]) + if (recheck[0]) return + + const doc = await upsertWebsiteProjectByRepo(fullName, { + projectName: repo.description || repo.name || fullName.split('/').pop(), + giteaRepoUrl: repo.html_url, + giteaRepoName: repo.name, + repoFullName: fullName, + archived: repoArchived, + status: repoArchived ? 'archived' : 'synced', + giteaSyncedAt: new Date().toISOString(), + }) + if (repoArchived) { + archivedImported += 1 + return + } + created += 1 + createdRepos.push({ projectId: doc.$id, repo }) + }) + } catch (err) { + failed.push(fullName) + console.error(`[importNewRepos] ${fullName} übersprungen:`, err.message) + } + } + + return { created, createdRepos, archivedImported, archiveSynced, failed } }) } @@ -104,6 +146,7 @@ export async function syncReposToAppwrite() { const repos = await listAllAccessibleRepos() let created = 0 let updated = 0 + const failed = [] for (const repo of repos) { const repoFullName = repo.full_name @@ -115,25 +158,38 @@ export async function syncReposToAppwrite() { ]) const payload = { - projectName: repo.description || repo.name || repoFullName.split('/').pop(), giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || repoFullName.split('/').pop(), repoFullName, // In Gitea archivierte Repos werden im Ticketsystem ausgeblendet. archived: Boolean(repo.archived), } + const repoTitle = repo.description || repo.name || repoFullName.split('/').pop() if (!existing[0]) { - payload.status = 'synced' + payload.projectName = repoTitle + payload.status = repo.archived ? 'archived' : 'synced' created += 1 } else { + // Der Name im Ticketsystem bleibt: frueher ueberschrieb der Abgleich ihn mit der + // Gitea-Beschreibung (aus "Döner Nation" wurde so "D-ner-Nation"). + if (!existing[0].projectName) payload.projectName = repoTitle + // Archiv-Stand aus Gitea uebernehmen, ohne dass status auf 'archived' haengen bleibt + if (repo.archived && existing[0].status !== 'archived') payload.status = 'archived' + if (!repo.archived && existing[0].status === 'archived') payload.status = 'synced' updated += 1 } - await upsertWebsiteProjectByRepo(repoFullName, payload) + try { + await upsertWebsiteProjectByRepo(repoFullName, payload) + } catch (err) { + // Ein fehlerhaftes Repo darf den Abgleich aller folgenden nicht abbrechen + failed.push(repoFullName) + console.error(`[syncReposToAppwrite] ${repoFullName} übersprungen:`, err.message) + } } - return { synced: repos.length, created, updated } + return { synced: repos.length, created, updated, failed } } export async function ensureSubdomainAvailable(subdomain) { @@ -148,7 +204,7 @@ export async function ensureSubdomainAvailable(subdomain) { } } -export async function generateRepoFromTemplate({ owner, repoName }) { +export async function generateRepoFromTemplate({ owner, repoName, description }) { const templateOwner = config.gitea.templateOwner const templateRepo = config.gitea.templateRepo const targetOwner = owner || config.gitea.defaultOwner @@ -162,8 +218,11 @@ export async function generateRepoFromTemplate({ owner, repoName }) { body: JSON.stringify({ owner: targetOwner, name: repoName, - private: false, - description: `Website-Projekt ${repoName}`, + // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04). Portal und Preview-Runner + // lesen Gitea mit dem System-Schluessel, Mitarbeiter sehen Org-Repos ueber das Team Owners. + private: true, + // Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort) + description: description || `Website-Projekt ${repoName}`, git_content: true, }), }) @@ -271,6 +330,37 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') { return previewConfig } +/** + * Vor dem Anlegen: gibt es das Repo in Gitea nicht (mehr), aber noch ein Projekt-Dokument mit dem + * Namen, wuerde das neue Projekt in diesen alten Eintrag uebernommen - samt archived, Art, Preview- + * Stand (und waere z. B. als archiviert unsichtbar). Platzhalter ohne Bezug werden entfernt, ein + * verknuepftes altes Projekt blockiert den Namen mit einer klaren Meldung - bevor ein Repo entsteht. + */ +async function releaseLeftoverProjectName(fullName) { + try { + await giteaFetch(`/repos/${fullName}`) + return // Repo existiert: generate meldet gleich "existiert bereits" + } catch (err) { + if (err.status !== 404) return + } + const leftovers = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', fullName), + Query.limit(10), + ]) + for (const old of leftovers) { + if (isUnlinkedProject(old)) { + await deleteDocument(config.collections.websiteProjects, old.$id) + continue + } + const error = new Error( + `Der Name "${fullName}" gehört noch zu einem alten Projekt ("${old.projectName || fullName}"` + + `${old.archived ? ', archiviert' : ''}). Bitte einen anderen Namen wählen.` + ) + error.status = 409 + throw error + } +} + export async function createProjectFromTemplate({ repoName, displayName, @@ -278,6 +368,8 @@ export async function createProjectFromTemplate({ customerId = '', ticketId = '', owner, + kind = '', + staffName = '', }) { const targetOwner = owner || config.gitea.defaultOwner const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain) @@ -289,14 +381,22 @@ export async function createProjectFromTemplate({ if (!safeRepoName) { throw new Error('repoName oder displayName ist erforderlich') } + if (kind && !isKind(kind)) { + const error = new Error('Unbekannte Projektart.') + error.status = 400 + throw error + } if (hasWebsite) { await ensureSubdomainAvailable(safeSubdomain) } + await releaseLeftoverProjectName(`${targetOwner}/${safeRepoName}`) + const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: safeRepoName, + description: displayName || safeRepoName, }) const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}` @@ -324,7 +424,11 @@ export async function createProjectFromTemplate({ } const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : '' - const project = await upsertWebsiteProjectByRepo(repoFullName, { + // Gitea meldet das neue Repo sofort per Webhook (repository/created, dann der Push der Config). + // Der Webhook legt das Dokument unter dieser Repo-Sperre an; ohne dieselbe Sperre legen beide + // gleichzeitig an und der Unique-Index auf repoFullName lehnt dieses hier ab ("Document with + // the requested ID already exists") - das Repo gab es dann schon, Kunde und Ticket fehlten. + const project = await withLocks([repoLockKey(repoFullName)], () => upsertWebsiteProjectByRepo(repoFullName, { customerId, ticketId, projectName: previewConfig.displayName, @@ -332,9 +436,24 @@ export async function createProjectFromTemplate({ giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || safeRepoName, repoFullName, - subdomain: safeSubdomain, + // Ohne Website null statt '': auf subdomain liegt ein Unique-Index, und ein zweites + // Projekt mit '' scheiterte mit "Document with the requested ID already exists". + subdomain: safeSubdomain || null, previewUrl, - projectType: hasWebsite ? 'website' : 'repo', + // Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem + projectType: hasWebsite ? 'preview' : 'repo', + archived: false, + // Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf + // "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment. + ...(kind + ? { + kind, + forCustomer: Boolean(customerId), + kindSetBy: staffName || '', + kindSetAt: new Date().toISOString(), + previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off', + } + : {}), ...(hasWebsite && deployKeepsStatus(deployResult) ? {} // Preview-Runner (C1): Status setzt der Verfolger : { @@ -343,14 +462,14 @@ export async function createProjectFromTemplate({ ? (deployResult.deployed ? 'ready' : 'deploy_failed') : 'ready', }), - }) + }), { waitMs: 10000 }) return { projectId: project.$id, repoFullName, previewUrl, subdomain: safeSubdomain, - projectType: hasWebsite ? 'website' : 'repo', + projectType: hasWebsite ? 'preview' : 'repo', deploy: deployResult, } } @@ -494,7 +613,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain, const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : '' const updated = await updateDocument(config.collections.websiteProjects, projectId, { projectName: nextName, - subdomain: nextSubdomain, + subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate) previewUrl, projectType: nextType, isPublic, @@ -550,30 +669,48 @@ export async function archiveRepo(projectId, archived = true) { const repoFullName = doc.repoFullName || '' let repoMissing = false - if (repoFullName.includes('/')) { - try { - await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) - } catch (err) { - // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem - // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. - if (err.status === 404) { - repoMissing = true - console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) - } else { - const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) - error.status = err.status || 500 - throw error + // Gitea und Dokument unter der Repo-Sperre aendern - dieselbe nimmt der Import, der den + // Archiv-Stand aus Gitea spiegelt; sonst koennte er einen alten Stand zurueckschreiben. + const locks = repoFullName ? [repoLockKey(repoFullName)] : [] + let updated = await withLocks(locks, async () => { + if (repoFullName.includes('/')) { + try { + await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) + } catch (err) { + // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem + // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. + if (err.status === 404) { + repoMissing = true + console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) + } else { + const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) + error.status = err.status || 500 + throw error + } } } - } - if (archived && doc.subdomain) { - try { await removeDeploy(doc.subdomain) } catch (err) { - console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) + // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner + // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. + // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. + return updateDocument(config.collections.websiteProjects, projectId, { + archived: Boolean(archived), + status: archived ? 'archived' : (!repoMissing && doc.subdomain ? 'pending' : 'synced'), + updatedAt: new Date().toISOString(), + }) + }, { waitMs: 10000 }) + + if (archived) { + if (doc.subdomain) { + try { await removeDeploy(doc.subdomain) } catch (err) { + console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) + } } + return { project: updated, archived: true } } - if (!archived && !repoMissing && doc.subdomain) { + if (!repoMissing && doc.subdomain) { + let after = null try { const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { @@ -583,19 +720,19 @@ export async function archiveRepo(projectId, archived = true) { subdomain: doc.subdomain, displayName: doc.projectName, } - await runDeploy(repoFullName, branch, cfg, { project: doc }) + const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc }) + if (deployResult?.deployed) after = { status: 'deployed', provisioningStatus: 'ready' } + else if (deployResult?.skipped) after = { status: 'synced' } // Preview ausgeschaltet + else if (!deployResult?.queued) after = { provisioningStatus: 'deploy_failed' } + // queued: der Runner-Verfolger setzt den Status nach dem Bau } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) + after = { provisioningStatus: 'deploy_failed' } } + if (after) updated = await updateDocument(config.collections.websiteProjects, projectId, after) } - const updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: Boolean(archived), - ...(archived ? { status: 'archived' } : {}), - updatedAt: new Date().toISOString(), - }) - - return { project: updated, archived: Boolean(archived) } + return { project: updated, archived: false } } /** Repo-Basisdaten (Groesse in KB, Default-Branch). */ diff --git a/server/services/giteaClient.js b/server/services/giteaClient.js index 003ac5f..32ce603 100644 --- a/server/services/giteaClient.js +++ b/server/services/giteaClient.js @@ -52,7 +52,7 @@ export async function giteaFetch(path, options = {}) { if (!response.ok) { let message = data?.message || `Gitea ${response.status}` if (response.status === 409 && /same name already exists/i.test(message)) { - message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' + message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen wählen.' } const error = new Error(message) error.status = response.status diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 2d83d5f..fff127a 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -802,7 +802,75 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project } } +/** + * Gehoert `subdomain` schon einem ANDEREN Projekt? Ein kopiertes Repo (git clone + push, Vorlage) + * bringt die .webklar-preview.json des Originals mit - ohne diese Pruefung ueberschrieb sein erster + * Push die Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). Ein in Gitea umbenanntes + * oder umgezogenes Repo ist nicht fremd: Gitea leitet den alten Namen auf `repoFullName` weiter. + * Ein Projekt ohne Repo (z. B. die Relaunch-Vorschau) haelt seine Subdomain ebenfalls. + * @returns {Promise} Repo-Name bzw. Projekt des fremden Besitzers, sonst '' + */ +export async function foreignSubdomainOwner(repoFullName, subdomain) { + const own = String(repoFullName || '').toLowerCase() + // ohne Repo gibt es nichts zu klonen - und ein Projekt ohne Repo sperrt sich nicht selbst + if (!subdomain || !own) return '' + const docs = await listDocuments(config.collections.websiteProjects, [ + Query.equal('subdomain', subdomain), + Query.limit(5), + ]) + for (const doc of docs) { + const other = String(doc.repoFullName || '') + if (!other) return `Projekt „${doc.projectName || doc.$id}“ (ohne Repo)` + if (other.toLowerCase() === own) continue + let resolved = '' + try { + resolved = String((await giteaFetch(`/repos/${other}`))?.full_name || '') + } catch { + // geloescht oder Gitea nicht erreichbar: im Zweifel fremd + } + if (resolved.toLowerCase() !== own) return other + } + return '' +} + +/** + * Ergebnis fuer einen gesperrten Deploy oder null. `failOpen`: ist die Pruefung selbst nicht + * moeglich (Appwrite weg), trotzdem deployen - so nutzt es runDeploy; der Webhook prueft vorher + * streng, und die WOMS-Wege lesen das Projekt ohnehin zuerst aus Appwrite. + */ +export async function foreignSubdomainBlock(repoFullName, subdomain, { failOpen = false } = {}) { + let owner + try { + owner = await foreignSubdomainOwner(repoFullName, subdomain) + } catch (err) { + if (!failOpen) throw err + console.error(`[previewDeploy] ${repoFullName}: Besitzer der Subdomain "${subdomain}" nicht pruefbar, deploye trotzdem:`, err.message) + return null + } + if (!owner) return null + console.warn(`[previewDeploy] ${repoFullName}: Subdomain "${subdomain}" gehoert ${owner} - kein Deploy`) + return { deployed: false, blocked: true, reason: 'subdomain_foreign', subdomain, owner } +} + +/** + * Muss runDeploy den Besitzer der Subdomain pruefen? Nicht, wenn der Aufrufer es schon getan hat + * (`subdomainChecked`, der Webhook) oder wenn es die schon vergebene Subdomain des uebergebenen + * Projekts selbst ist. + */ +function needsOwnerCheck(repoFullName, subdomain, { project, subdomainChecked } = {}) { + if (subdomainChecked) return false + const same = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase() + return !(project?.subdomain && same(project.subdomain, subdomain) && same(project.repoFullName, repoFullName)) +} + export async function runDeploy(repoFullName, branch, previewConfig, options = {}) { + // Vor dem Runner pruefen: ein gesperrter Aufruf belegt dort keinen Platz und laesst so den + // Verfolger eines laufenden Baus desselben Repos in Ruhe. + const target = previewConfig.subdomain || repoFullName.split('/').pop() + if (needsOwnerCheck(repoFullName, target, options)) { + const blocked = await foreignSubdomainBlock(repoFullName, target, { failOpen: true }) + if (blocked) return blocked + } if (config.previewRunner.enabled) { return runDeployViaRunner(repoFullName, branch, previewConfig, options) } diff --git a/server/services/repoAssignJob.js b/server/services/repoAssignJob.js index d1fadc0..e513542 100644 --- a/server/services/repoAssignJob.js +++ b/server/services/repoAssignJob.js @@ -149,7 +149,10 @@ export async function runRepoAssignJob({ const docs = await listDocuments(collection(), [Query.limit(5000)]) for (const p of docs) { - if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue + if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue + // Vom System-Konto angelegt (WOMS "Neues Projekt"): kein Mitarbeiter, den man erinnern + // koennte - sonst alle 10 Minuten Gitea-Abfrage und Log "keinem Mitarbeiter zugeordnet". + if (String(p.createdByGitea).toLowerCase() === String(config.gitea.systemUser || '').toLowerCase()) continue // Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h. const attempts = Number(p.assignMailAttempts || 0) if (!p.assignMailSentAt && attempts < 3) { diff --git a/server/services/repoMove.js b/server/services/repoMove.js index 17d1ce0..ad963de 100644 --- a/server/services/repoMove.js +++ b/server/services/repoMove.js @@ -59,6 +59,8 @@ function sameRepoFullName(a, b) { export async function isInternalRepo(repoFullName) { const lower = repoFullName.toLowerCase() if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true + // Die Vorlage fuer "Neues Projekt" darf nie umziehen, sonst findet generate sie nicht mehr + if (`${config.gitea.templateOwner}/${config.gitea.templateRepo}`.toLowerCase() === lower) return true const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) const portalUrl = portalWebhookUrl() diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 88e3cec..16d1db8 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -72,7 +72,8 @@ export async function getFeatureDoc(customerId, projectId, key) { } } -export async function assertOwnProject(customerId, projectId) { +/** forNewOrder: Neu bestellen/einschalten geht auf archivierten Projekten nicht mehr (Kunde sieht sie nicht). */ +export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) { let project = null try { project = await getDocument(config.collections.websiteProjects, projectId) @@ -80,6 +81,9 @@ export async function assertOwnProject(customerId, projectId) { if (err.status !== 404) throw err } if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt') + if (forNewOrder && project.archived) { + throw httpError(409, 'Dieses Projekt ist archiviert - dafür kann nichts mehr bestellt oder eingeschaltet werden.') + } return project } @@ -149,14 +153,19 @@ export function listCustomerFeatureDocs(customerId) { return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)]) } -/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */ -export async function listCustomerFeatures(customerId, { docs: geladen } = {}) { - const projects = await listDocuments(config.collections.websiteProjects, [ +/** + * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. + * includeArchived: nur fuer die Mitarbeiter-Ansicht. Der Kunde sieht archivierte Projekte nur noch, solange dort + * eine Funktion bestellt/eingerichtet/aktiv ist - sonst kaeme er z. B. nicht mehr an seine Terminbuchungen. + */ +export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) { + const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) + const laufend = new Set(docs.filter((d) => ['ordered', 'setup', 'active'].includes(d.state)).map((d) => d.projectId)) + const projects = (await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), - ]) - const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) + ])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id)) const result = [] for (const p of projects) { const features = {} diff --git a/test/analyseCards.test.js b/test/analyseCards.test.js new file mode 100644 index 0000000..2be00c0 --- /dev/null +++ b/test/analyseCards.test.js @@ -0,0 +1,404 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { headerHtml, tabsHtml, overviewHtml, summaryHtml, kpisHtml, sparkHtml, chartHtml, chartTipText, contactsHtml, rankCardHtml, basisHtml, heatTeaserHtml, skeletonHtml, errorHtml, settingsHtml } from '../public/analyse-cards.js' + +// Testdaten: Form wie analytics/src/stats.js (gleiche Helfer wie test/analyseInsights.test.js) +const isoDay = (from, n) => new Date(Date.UTC(+from.slice(0, 4), +from.slice(5, 7) - 1, +from.slice(8, 10) + n)).toISOString().slice(0, 10) +const days = (from, n, pv) => Array.from({ length: n }, (_, i) => ({ day: isoDay(from, i), pv: typeof pv === 'function' ? pv(i, isoDay(from, i)) : pv })) +const rich = (rows) => rows.map((d) => ({ ...d, visitors: 80, contacts: 6, avgSec: 100 })) +const count = (html, re) => (html.match(re) || []).length +const t = { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 } +const prev = { + from: '2026-08-06', to: '2026-09-04', complete: true, + totals: { pv: 6080, visitors: 2200, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 }, + series: rich(days('2026-08-06', 30, 203)), + contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }, { key: 'wa', count: 23 }], +} +const stats = (over = {}) => ({ + range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01', + today: { day: '2026-10-05', pv: 42, visitors: 20, contacts: 1 }, + totals: t, + series: rich(days('2026-09-05', 30, (i) => (i === 14 ? 371 : 231))), + pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }], + refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }], + countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: '', count: 330 }], + devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2200 }, { key: 't', count: 140 }], + contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'wa', count: 30 }, { key: 'out', count: 10 }], + banner: { shown: 3950, accept: 2410, reject: 1540 }, + previous: prev, + ...over, +}) +const full = stats() +const zeroTotals = { pv: 0, visitors: 0, contacts: 0, consentRate: 0, engaged: 0, avgSec: null, avgScroll: null } +const zero = { + range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '', + totals: zeroTotals, + series: days('2026-09-05', 30, 0).map((d) => ({ ...d, visitors: 0, contacts: 0, avgSec: null })), + pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 0, accept: 0, reject: 0 }, + previous: { from: '2026-08-06', to: '2026-09-04', complete: false, totals: zeroTotals, series: [], contacts: [] }, +} +const OLD = { totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }, { day: '2026-09-18', pv: 30 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } } +const project = (over = {}) => ({ projectId: 'p1', projectName: 'Boulderhalle Überhang', analyse: { state: 'active', devMode: false, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal: { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }, activatedAt: '2026-10-01T22:30:00.000Z' }, ...over }) + +test('overviewHtml: volle Daten – Kacheln mit Chips, Vorzeitraum-Linie, Kontakte, Quote', () => { + const h = overviewHtml(full) + assert.match(h, /▲ 14 %/); assert.match(h, /vs\. 6\.080/); assert.match(h, /class="an-prev"/) + assert.match(h, /Ø Zeit pro Seite/); assert.match(h, /1:48/); assert.match(h, /Ø 64 % gescrollt/) + assert.match(h, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\./) + assert.match(h, /61 % haben dem Banner zugestimmt \(2\.410 von 3\.950 Entscheidungen\)/) + assert.match(h, /Das Wichtigste · 5\. Sep\. – 4\. Okt\./) + assert.match(h, /14 % mehr<\/b>/) // Satz aus summarize + assert.match(h, /Länderdaten: IP-Geolocation by DB-IP<\/a>/) + assert.match(h, /data-rank="seen"/); assert.match(h, /data-rank="who"/); assert.match(h, /data-tab="heatmap"/) + assert.doesNotMatch(h, /undefined|NaN|Infinity|null/) +}) + +test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => { + const h = overviewHtml(OLD) + assert.doesNotMatch(h, /▲|▼|undefined|NaN|null/); assert.doesNotMatch(h, /class="an-prev"/) + assert.doesNotMatch(h, /an-delta/); assert.doesNotMatch(h, /Vergleich ab/) + assert.doesNotMatch(h, /Ø \d+ % gescrollt/) // kein avgScroll + assert.match(h, /612<\/b>/) + assert.equal(count(h, / { + const h = overviewHtml(zero) + assert.match(h, /Sobald ein paar mehr Besuche da sind/); assert.match(h, /Noch hat niemand über das Banner entschieden\./) + assert.match(h, /Noch keine Kontakt-Aktionen\. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt\./) + assert.doesNotMatch(h, /NaN|Infinity|undefined|null/) + assert.doesNotMatch(h, /an-delta|Vergleich ab/) // firstDay '' => kein Datum + assert.match(overviewHtml({}), /Sobald ein paar mehr Besuche da sind/) // sogar eine leere Antwort bricht nichts + assert.doesNotMatch(overviewHtml({}), /NaN|Infinity|undefined|null/) +}) + +test('summaryHtml: Satz und Hinweise mit Symbol; Leerzustand unter 30 Aufrufen', () => { + const h = summaryHtml(full) + assert.match(h, /Deine Website wurde in den letzten 30 Tagen 6\.930-mal<\/b> aufgerufen/) + assert.equal(count(h, /
  • /g), 4); assert.match(h, /