Merge origin/main in feature/terminplaner-tp2 (Analyse-Cockpit, Subdomain-Sperre, Projekte)

Konflikte: funktionen.js folgt der Aufteilung aus main (fn-util.js, analyse.js); der Link
'Zu Verträge' der Analyse-Seite kommt jetzt auch in analyse.js aus vertraege-link.js.
Checkout und Team-Ansicht übernehmen assertOwnProject(..., { forNewOrder: true }) und
includeArchived aus main. Test-Pins der absoluten Importe nachgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:11:58 +00:00
commit af63b69ec1
56 files changed
+4583 -717

No files matched your search

+94 -26
View File
@@ -1,69 +1,137 @@
// Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten. // Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten.
import { dayOf, aggregateRows } from './jobs.js'; import { dayOf, aggregateRows } from './jobs.js';
const DAY_MS = 86400 * 1000;
const TOP = 20; const TOP = 20;
// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Berlin-Tag von `now`. // Kalendertag (YYYY-MM-DD) um `n` Tage verschoben, rein kalendarisch (UTC-Datumsrechnung, keine Zeitumstellung).
function dayList(now, range) { function shiftDay(day, n) {
const [y, m, d] = dayOf(now).split('-').map(Number); 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 = []; 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; return days;
} }
const top = (map) => [...map].map(([key, count]) => ({ key, count })).filter((e) => e.count > 0) 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); .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()) { // Sammelt die Zähler für eine zusammenhängende Tagesliste: verdichtete Tage aus `daily`,
const days = dayList(now, range); // 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 first = days[0];
const last = days[days.length - 1];
const inRange = new Set(days); 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 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(); if (!dims[dim]) dims[dim] = new Map();
dims[dim].set(key, (dims[dim].get(key) || 0) + n); 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)); 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 >= ?').all(site, first)) { 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); 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 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') 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); const d = dayOf(r.ts);
if (!inRange.has(d) || done.has(d)) continue; if (!inRange.has(d) || done.has(d)) continue;
if (!live.has(d)) live.set(d, []); if (!live.has(d)) live.set(d, []);
live.get(d).push(r); live.get(d).push(r);
} }
for (const [day, rows] of live) for (const [dim, key, n] of aggregateRows(rows)) apply(day, dim, key, n); 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 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 banner = { shown: sum('bn_shown'), accept: sum('bn_accept'), reject: sum('bn_reject') };
const decided = banner.accept + banner.reject; const decided = banner.accept + banner.reject;
const contacts = top(dims.ct_kind || new Map()); const en = sum('en_count');
return { return {
banner,
totals: { totals: {
pv: sum('pv_total'), pv: sum('pv_total'),
// Summe der Tages-Besucher (kein tagesübergreifender Abgleich möglich: Salz wechselt täglich) // Summe der Tages-Besucher (kein tagesübergreifender Abgleich möglich: Salz wechselt täglich)
visitors: sum('visitors'), visitors: sum('visitors'),
contacts: sum('ct_kind'), contacts: sum('ct_kind'),
consentRate: decided ? banner.accept / decided : 0, 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,
}; };
} }
+1 -1
View File
@@ -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\{[^}]*background:#0a5a8a[^}]*color:#fff/);
assert.match(css, /button\{[^}]*[; ]width:150px/); assert.match(css, /button\{[^}]*[; ]width:150px/);
assert.equal((css.match(/(?<![-\w])width:/g) || []).length, 2, 'eine feste Breite fuer beide + 100% mobil'); assert.equal((css.match(/(?<![-\w])width:/g) || []).length, 2, 'eine feste Breite fuer beide + 100% mobil');
const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'funktionen.css'), 'utf8'); const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'analyse.css'), 'utf8');
assert.match(portal, /\.an-bn-btn \{[^}]*[; ]width: 150px/); assert.match(portal, /\.an-bn-btn \{[^}]*[; ]width: 150px/);
assert.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel'); assert.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel');
assert.equal(host.style.zIndex, '2147483647'); assert.equal(host.style.zIndex, '2147483647');
+171 -15
View File
@@ -115,12 +115,13 @@ async function admin(db, now = () => NOW) {
return { get, close: () => server.close() }; 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 { db, ev } = mk();
const y = NOW - D; 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' }); 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 + 10, type: 'en', path: '/a', vp: 'd', visitor: 'v1', sec: 5, scroll: 5 });
ev({ ts: y + 11, type: 'bn', kind: 'accept' }); 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 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: 'pv', path: '/b', vp: 'm', country: 'AT', device: 'm' }); // heute live
ev({ ts: NOW - H, type: 'ct', path: '/b', visitor: 'v9', kind: 'mail' }); 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`); const r = await a.get(`/a/admin/stats/${KEY}?range=7`);
assert.equal(r.status, 200); assert.equal(r.status, 200);
const j = await r.json(); 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.length, 7);
assert.equal(j.series[0].day, '2026-10-04'); assert.equal(j.series[0].day, '2026-10-03');
assert.equal(j.series[6].day, '2026-10-10'); assert.equal(j.series[6].day, '2026-10-09');
assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]); assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 0, 3]);
assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4 }); assert.deepEqual(j.totals, { pv: 3, visitors: 1, contacts: 1, consentRate: 1, engaged: 1, avgSec: 5, avgScroll: 5 });
assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]); 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.refs[0], { key: 'g.de', count: 3 });
assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]); assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }]);
assert.deepEqual(j.devices, [{ key: 'd', count: 3 }, { key: 'm', count: 1 }]); assert.deepEqual(j.devices, [{ key: 'd', count: 3 }]);
assert.deepEqual(j.contacts, [{ key: 'mail', count: 1 }]); assert.deepEqual(j.contacts, [{ key: 'tel', count: 1 }]);
assert.deepEqual(j.banner, { shown: 1, accept: 2, reject: 2 }); assert.deepEqual(j.banner, { shown: 0, accept: 1, reject: 0 });
// range 90: 90 Tage // range 90: 90 Tage
const j90 = await (await a.get(`/a/admin/stats/${KEY}?range=90`)).json(); const j90 = await (await a.get(`/a/admin/stats/${KEY}?range=90`)).json();
assert.equal(j90.series.length, 90); 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(); 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 () => { test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', async () => {
const { db, ev } = mk(); const { db, ev } = mk();
ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); 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); 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 }); 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(); a.close();
}); });
test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => { test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => {
const { db, ev } = mk(); 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 a = await admin(db);
const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
assert.equal(j.pages.length, 20); assert.equal(j.pages.length, 20);
@@ -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 <noreply@anthropic.com>`.
## 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** (`/<img src=x onerror=alert(1)>`, 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(<Tag vor days[0]>, 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<object>` (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 `<b>`.
- `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 <b>6.930-mal</b> aufgerufen – <b>14 % mehr</b> als in den 30 Tagen davor.')
assert.equal(s.items[0].icon, 'contact')
assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.')
assert.ok(s.items.length <= 4)
})
test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { /* '– <b>9 % weniger</b> als', '– etwa so oft wie in den 30 Tagen davor.', '– <b>12 Aufrufe mehr</b> als' */ })
test('summarize: ohne Vergleich und neu gestartet', () => {
// previous fehlt: '…in den letzten 30 Tagen <b>X-mal</b> aufgerufen.'
// firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>X-mal</b> 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: '/<img src=x>', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] }))
assert.ok(!JSON.stringify(s).includes('<img'))
assert.ok(JSON.stringify(s).includes('&lt;img'))
})
test('chartModel mit compare: gemeinsame Skala, prevLine', () => { /* 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 `<select id="an-project">` nur bei `usable.length > 1`; Zeitraum-Knöpfe `.an-seg button[data-range]` nur bei `showRange`; Status „Analyse aktiv · Stand: heute, 19:42 Uhr“ (`stamp` = `'19:42'`) bzw. gelb „Im Dev-Modus wird nicht gemessen.“
- `tabsHtml(tab)` – `role="tablist"`, Knöpfe `[data-tab="uebersicht|heatmap|einstellungen"]`, `aria-selected`.
- `overviewHtml(stats)` – setzt zusammen: `summaryHtml`, `kpisHtml`, Zeile `.an-row2` (`chartHtml` + `contactsHtml`), `rankCardHtml('seen', …)` + `rankCardHtml('who', …)`, `basisHtml` + `heatTeaserHtml`, Fußzeile „Länderdaten: IP-Geolocation by DB-IP“ (Link wie heute).
- `summaryHtml(stats)`, `kpisHtml(stats)`, `sparkHtml(values)`, `chartHtml(stats)`, `contactsHtml(stats)`, `rankCardHtml(card, stats, { tab, expanded } = {})` mit `card` `'seen'` (Reiter `pages` | `refs`) oder `'who'` (`devices` | `countries`), `basisHtml(stats)`, `heatTeaserHtml()` (Knopf `[data-tab="heatmap"]`), `skeletonHtml()`, `errorHtml(message)` (Knopf `#an-retry`), `settingsHtml(project)` (Status, Unterlagen, leere `<section class="an-card" id="an-off">`).
- Texte exakt aus Spec 2.1–2.4 (Leerzustand „Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.“, Quote „Auf 100 Besucher kommen 7,8 Kontakt-Aktionen (davor 6,5).“, „Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.“, Datenbasis-Sätze, „Noch hat niemand über das Banner entschieden.“, „Vergleich ab TT.MM.“).
- [ ] **Step 1: Failing Tests schreiben** (`test/analyseCards.test.js`; Datenhelfer wie in Task 3)
```js
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\)/)
})
test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => {
const h = overviewHtml({ totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } })
assert.doesNotMatch(h, /▲|▼|undefined|NaN/); assert.doesNotMatch(h, /class="an-prev"/)
})
test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => {
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.doesNotMatch(h, /NaN|Infinity|undefined|null/)
})
test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => {})
test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => {})
test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => {
const h = rankCardHtml('seen', stats({ pages: [{ key: '/<img src=x>', count: 9 }] }))
assert.match(h, /&lt;img src=x&gt;/); assert.doesNotMatch(h, /<img src=x>/)
})
test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => {})
test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {})
test('errorHtml und skeletonHtml', () => { assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/) })
```
- [ ] **Step 2: Tests laufen, schlagen fehl**
Run: `node --test test/analyseCards.test.js` → FAIL
- [ ] **Step 3: `public/analyse-cards.js` implementieren**
Klassen-Präfix `an-` (Karten `.an-card`, Kacheln `.an-kpi`, Chips `.an-delta.up|down|flat` mit `aria-label`, Mini-Verlauf `.an-spark`, Diagramm `.an-chart` mit `.an-area`, `.an-line`, gestrichelt `.an-prev` (nur wenn `compareFrom(stats).ok`), Legende „<range> Tage“ / „Zeitraum davor“ (zweiter Eintrag nur mit Vergleich), Punkte mit `data-i`, Daten für die Blase als `data-day`/`data-pv`/`data-prev` am Punkt). Mini-Verläufe: Seitenaufrufe `series[].pv`, Besucher `series[].visitors`, Kontakte `series[].contacts`, Zeit `series[].avgSec` (fehlende Werte als 0, ganz fehlende Reihe → kein Verlauf). Symbole als Inline-SVG (gleicher Stil wie `ICONS` in `funktionen.js`, `stroke`-basiert), keine Emoji.
- [ ] **Step 4: Tests grün, Suite grün**
Run: `node --test test/analyseCards.test.js && npm test` → PASS
- [ ] **Step 5: Commit**
```bash
git add public/analyse-cards.js test/analyseCards.test.js
git commit -m "Analyse: HTML-Bausteine der Cockpit-Übersicht"
```
---
### Task 5: Cockpit verdrahten – Rahmen, Reiter, Übersicht, Heatmap, Einstellungen, Stil
**Files:**
- Modify: `public/analyse.js`
- Create: `public/analyse.css`
- Modify: `public/dashboard.html` (Zeile 24: `<link rel="stylesheet" href="/analyse.css">` nach `funktionen.css`)
- Modify: `public/funktionen.css:140-218` (Analyse-Abschnitt entfernen; was die Einschalten-Ansicht und Heatmap brauchen, zieht nach `analyse.css`)
- Modify: `test/ui/analyse.ui.mjs`
**Interfaces:**
- Consumes: Task 2 (`analyseSel`, `request`, `esc`, `safeUrl`, bestehende Heatmap-Funktionen in `analyse.js`), Task 3, Task 4 (alle Bausteine).
- Produces: `initAnalyse(root, { projectId })` rendert `<div class="an">` mit `headerHtml` + (nur Zustand `active`) `tabsHtml` + `<div id="an-stage">`. Bestehende Exporte bleiben.
Verhalten:
- Zustand `active`: Reiter aus `analyseSel.tab`. Übersicht: erst `skeletonHtml()`, dann `overviewHtml(stats)`; Fehler → `errorHtml(…)`, `#an-retry` lädt neu; Zähler `statsRequestId` verwirft veraltete Antworten (wie heute). Zeitraum-Knöpfe setzen `analyseSel.range` und laden nur die Übersicht neu. `#an-project` wechselt die Website (Reiter bleibt). Ranglisten-Umschalter und „Alle anzeigen“ rendern nur ihre Karte neu (`rankCardHtml`). `[data-tab="heatmap"]` im Teaser wechselt den Reiter.
- Diagramm-Blase: Zeigen/Tippen auf `.an-chart` wählt den nächsten Punkt (`data-i`) und zeigt `.an-tip` mit „Sa., 19.09.: 371 Aufrufe (davor 210)“ (ohne Vorwert ohne Klammer); verlassen → ausblenden.
- Heatmap-Reiter: `renderAnalyseHeat` wie heute; die Seitenliste kommt aus der zuletzt geladenen Statistik (sonst einmal `range=30` laden). iframes entstehen erst beim Öffnen des Reiters.
- Einstellungen-Reiter: `settingsHtml(project)`, danach `renderAnalyseOff(stage.querySelector('#an-off'), projectId, reload)`.
- Andere Zustände (Einschalten, gesperrt, nicht verfügbar, unbekannt, keine nutzbare Website): Kopfleiste ohne Zeitraum und ohne Reiter, Inhalt im Cockpit-Rahmen, Texte unverändert. `root.querySelector` kann in Node-Tests für andere Selektoren als `#an-stage` `null` liefern – überall `?.` verwenden.
- `analyse.css`: Werte aus Global Constraints und Spec 2.5; Raster Desktop wie Entwurf A (`/root/reports/analyse-ueberarbeitung-2026-10-04/entwurf-a-cockpit.png`); `.an-row2` mit `align-items: stretch`, Diagramm füllt die Höhe; ≤ 880 px: Kacheln 2 × 2, alles einspaltig, Reiter `overflow-x: auto`, Zeitraum volle Breite, lange Namen mit `text-overflow: ellipsis`; `@media (prefers-reduced-motion: reduce)` ohne Übergänge.
- [ ] **Step 1: UI-Test erweitern (schlägt fehl)** – `test/ui/analyse.ui.mjs`
Neue Szenarien in `SCENARIOS`/Statistik: `full` (30 Tage, Werte wie Fixture „Boulderhalle Überhang“: pv 6.930/Vorzeitraum 6.080, visitors 2.410/2.210, contacts 188/143, avgSec 108/114, avgScroll 64, consentRate 0,61, banner 3.950/2.410/1.540, `firstDay` vor dem Vorzeitraum), `neu` (`firstDay` im Zeitraum, `previous.complete` false), `wenig` (pv 12), `dev` (`devMode: true`), `alt` (ohne `previous`), `langerPfad` (Seite `/` + 120 Zeichen), `fehler` (Statistik 503). Prüfungen je Viewport 1440 × 1000 und 402 × 874:
```
OK aktiv: Block "Das Wichtigste" mit "14 % mehr"
OK aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼
OK Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes
OK Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"
OK Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht
OK verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht
OK neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse"
OK wenig: Leerzustand
OK dev: "Im Dev-Modus wird nicht gemessen."
OK alt: keine Chips, keine pageerror
OK fehler: "Auswertung gerade nicht erreichbar" + Neu laden
OK langer Pfad, 402 px: document.scrollingElement.scrollWidth <= innerWidth
OK Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher)
OK keine pageerror in allen Zuständen
```
Screenshots je Zustand und Viewport nach dem Ordner aus `argv[2]`.
Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5` → FAIL
- [ ] **Step 2: `analyse.js` verdrahten, `analyse.css` schreiben, `dashboard.html` und `funktionen.css` anpassen**
- [ ] **Step 3: UI-Test grün**
Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5`
Expected: nur `OK`-Zeilen.
- [ ] **Step 4: Screenshots ansehen und mit Entwurf A vergleichen**
Desktop- und Handy-Bilder von `full`, `neu`, Einschalten und Einstellungen öffnen (Read-Tool). Erwartet: Aufbau wie Entwurf A, Diagramm und Kontaktkarte gleich hoch, keine abgeschnittenen Texte, keine Überlappungen, Kacheln auf dem Handy 2 × 2. Abweichungen beheben und Step 3 wiederholen.
- [ ] **Step 5: Suite grün**
Run: `npm test` → PASS, 0 fail
- [ ] **Step 6: Commit**
```bash
git add public/analyse.js public/analyse.css public/dashboard.html public/funktionen.css test/ui/analyse.ui.mjs
git commit -m "Analyse: Cockpit-Übersicht mit Reitern Übersicht, Heatmap, Einstellungen"
```
---
### Task 6: Abschlussprüfung auf Node 20 und Bilder für den Nutzer
**Files:**
- keine Code-Dateien (nur Prüfung); Bilder nach `/root/reports/analyse-ueberarbeitung-2026-10-04/screens/`
- [ ] **Step 1: Portal-Suite auf Node 20**
Run: `docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu node:20-alpine node --test test/*.test.js 2>&1 | tail -8`
Expected: `fail 0` (bekannte Ausnahme laut Notizen: nur `sharp`-abhängige Tests, falls `sharp` für musl fehlt – dann diese benennen, nicht ignorieren).
- [ ] **Step 2: Collector-Suite auf Node 24**
Run: `cd analytics && npm test` → `fail 0`
- [ ] **Step 3: Bilder für den Nutzer**
Run: `node test/ui/analyse.ui.mjs /root/reports/analyse-ueberarbeitung-2026-10-04/screens`
Expected: nur `OK`; Bilder `full`, `neu`, Heatmap, Einstellungen, Einschalten je Desktop/Handy liegen im Ordner.
- [ ] **Step 4: Branch-Zustand festhalten**
Run: `git status --short` (nur `?? node_modules`) und `git log --oneline origin/main..HEAD` (Spec, Plan, Tasks 1–5).
---
## Livegang (nur mit OK des Nutzers, nicht Teil der Tasks)
- **L1** Nutzer pusht: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu push origin feature/analyse-uebersicht:main` (vorher ggf. auf `origin/main` rebasen und `npm test` erneut).
- **L2** Prüfen: `build/` auf dem neuen Stand; `docker ps` zeigt `webklar-kundenbereich-server` und `webklar-analytics` frisch gestartet; `curl -s https://project.webklar.com/a/health`; im Container Stats einer echten aktiven Website abrufen (über `analyticsClient.getStats(siteKey, 30)` in `docker exec -w /app webklar-kundenbereich-server node -e …`) → Felder `previous`, `firstDay`, `totals.avgSec` vorhanden; `https://project.webklar.com/analyse.js` und `/analyse.css` 200.
- **L3** Nutzer öffnet Funktionen → Analyse einer Website mit aktiver Analyse und testet Reiter, Zeitraum, Handy.
- **L4** Notizen aktualisieren (Memory `analyse-uebersicht-neu.md`).
@@ -0,0 +1,245 @@
# Analyse-Übersicht neu („Cockpit“) – Design
Stand: 2026-10-04 · Status: Aufbau, Design-Richtung und Abschnitte 1–3 vom Nutzer im Chat freigegeben, Spec zur Durchsicht
Baut auf der Website-Analyse auf (Spec `2026-10-01-website-analyse-design.md`, live seit 01.10.2026).
Recherche: `/root/reports/analyse-ueberarbeitung-2026-10-04/recherche.md` (30 Tools, 25 Features, UI-Muster).
Entwürfe: `/root/reports/analyse-ueberarbeitung-2026-10-04/entwurf-a-cockpit.png` (gewählt), `entwurf-b-bericht.png`.
Regel des Nutzers: bauen → live (Nutzer pusht `main`) → Nutzer testet live.
## Ziel
Die Analyse-Seite im Kundenportal (Funktionen → Analyse) soll für kleine Betriebe ohne Analytics-Kenntnisse
**auf einen Blick** sagen, wie die Website läuft: ein Satz in Klartext, Vergleich zum Vorzeitraum,
Kontakt-Aktionen als Hauptzahl. Das Interface darf sich vom restlichen Portal abheben, muss aber
aufgeräumt und einheitlich sein.
## Entscheidungen (vom Nutzer, 04.10.2026)
| # | Entscheidung |
|---|---|
| U1 | Umfang: nur das Paket **„Übersicht neu“** (Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Klartext-Zusammenfassung, Kontakt-Aktionen als Hauptzahl, Datenbasis-Hinweis). **Nicht** in diesem Projekt: Besucher-Details (Wochentag × Uhrzeit, Kanäle, Zeit je Seite), Website-Check (Erreichbarkeit, SSL, Links, SEO), Bericht per Mail. |
| U2 | Design-Richtung **A „Cockpit“**: dunkel wie das Portal, eigene Akzentfarbe Türkis nur innerhalb der Analyse, leuchtender Block „Das Wichtigste“. |
| U3 | Aufbau mit drei Reitern **Übersicht · Heatmap · Einstellungen**; Heatmap und „Ausschalten“ hängen nicht mehr unten an der Übersicht. |
| U4 | Nur **vorhandene Daten**. Keine Änderung an Messskript (`w.js`, `h.js`), Banner, AVV, Leistungsbeschreibung, Datenschutz-Zusatzseite oder Abo-Bedingungen. |
| U5 | Klartext entsteht durch **feste Regeln im Browser**, keine KI, kein externer Dienst. |
Rechtliche Einordnung (keine Rechtsberatung): Der Vergleich nutzt die Tageswerte des Vorzeitraums (bis zu 180
Tage zurück; Tageswerte werden 25 Monate aufbewahrt). Durchschnitte und Tageswerte für Besucher, Kontakte und
Verweildauer stammen aus bereits erhobenen und verdichteten Daten (`daily`: `visitors`, `ct_kind`,
`en_count`, `en_sec_sum`, `scroll_sum`). Die Leistungsbeschreibung (§ 5: „Kennzahlen und Diagramme für die
letzten 7, 30 oder 90 Tage“) deckt das ab; kein neuer Text, keine neue Einwilligung.
## 1. Daten: Statistik-Antwort des Collectors
`GET /a/admin/stats/:siteKey?range=7|30|90` (interner Listener, `analytics/src/stats.js`) liefert **zusätzlich**
zu den bisherigen Feldern (alle bisherigen Felder bleiben unverändert, damit der Wechsel abwärtskompatibel ist):
```js
{
range: 30,
from: '2026-09-04', to: '2026-10-03', // Berliner Kalendertage des Zeitraums: abgeschlossene Tage (to = gestern)
today: { day: '2026-10-04', pv, visitors, contacts }, // heute bis jetzt, live, nicht im Zeitraum enthalten
firstDay: '2026-08-20', // frühester Berliner Tag mit Daten dieser Website ('' = keine)
totals: {
pv, visitors, contacts, consentRate, // wie bisher
engaged, // Zahl der en-Ereignisse (Seitenbesuche mit Zustimmung und Abschluss)
avgSec, // Ø Sekunden pro Seitenbesuch = en_sec_sum / en_count, gerundet; null wenn engaged = 0
avgScroll, // Ø Scrolltiefe in % = scroll_sum / en_count, gerundet; null wenn engaged = 0
},
series: [{ day, pv, visitors, contacts, avgSec }], // bisher nur {day, pv}; avgSec je Tag null ohne en
pages, refs, countries, devices, contacts, banner, // unverändert
previous: {
from: '2026-08-06', to: '2026-09-04', // die `range` Kalendertage direkt vor `from`
complete: true, // firstDay !== '' && firstDay <= previous.from
totals: { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll },
series: [{ day, pv, visitors, contacts, avgSec }],
contacts: [{ key, count }], // je Art, für die Veränderung pro Zeile
},
}
```
Regeln:
- Tageslisten werden **kalendarisch** gebildet (Berliner Tage, wie `dayList`), nicht durch Abziehen von
`range × 24 h` – sonst verrutscht der Vorzeitraum bei der Zeitumstellung um einen Tag.
- Gemeinsame Hilfsfunktion sammelt die Dimensionen für eine beliebige Tagesliste: verdichtete Tage aus `daily`,
nicht verdichtete Tage live aus `events_raw` (wie heute). Kein Tag wird doppelt gezählt.
- `firstDay` = kleinster Tag aus `daily` und `events_raw` dieser Website (`MIN(day)` bzw. `dayOf(MIN(ts))`).
- `visitors` bleibt die Summe der Tages-Besucher (Salz wechselt täglich, kein tagesübergreifender Abgleich).
- Der Portal-Server reicht die Antwort unverändert durch (`GET /api/funktionen/analyse/stats`).
Portal-Server, einzige weitere Änderung: `analyseView` (Route `GET /api/funktionen`) gibt zusätzlich
`activatedAt` aus dem Analyse-Dokument zurück (`null`, wenn nicht aktiv).
Nachtrag 04.10.2026 (Entscheidung im Final-Review, F1): Der Zeitraum besteht aus **abgeschlossenen** Tagen
(endet gestern), damit der angebrochene heutige Tag nicht gegen volle Vortage verglichen wird (sonst zeigte z. B.
der 7-Tage-Blick tagsüber fälschlich „weniger“). Der heutige Stand steht getrennt in `today` und in der Kopfleiste
(„Heute bisher: 42 Aufrufe“). Der Satz „direkt kontaktiert“ zählt nur Telefon, E-Mail und WhatsApp (F2).
## 2. Oberfläche
### 2.1 Aufbau der Seite (Zustand „aktiv“)
```
[ Kopfleiste: ● Website-Name · „Analyse aktiv · Heute bisher: 42 Aufrufe · Stand: 19:42 Uhr“ (Website-Auswahl) [7 | 30 | 90 Tage] ]
[ Reiter: Übersicht | Heatmap | Einstellungen ]
Übersicht:
[ Das Wichtigste · 5. Sep. – 4. Okt. — Klartext-Satz, darunter bis zu 4 Hinweise mit Symbol ]
[ Seitenaufrufe ] [ Besucher* ] [ Kontakt-Aktionen* ] [ Ø Zeit pro Seite* ] je: Zahl, ▲/▼-Chip, „vs. <Vorwert>“, Mini-Verlauf
[ Seitenaufrufe pro Tag (Linie + gestrichelter Vorzeitraum) ] [ Kontakt-Aktionen: Anrufe, E-Mails, WhatsApp, Links nach außen + Quote ]
[ Was angesehen wird: Seiten | Herkunft ] [ Wer zu Besuch ist: Geräte | Länder ]
[ Datenbasis: Ring mit Zustimmungsquote + Erklärung ] [ Heatmap: Vorschaubild + „Öffnen“ → Reiter Heatmap ]
Fußzeile: „Länderdaten: IP-Geolocation by DB-IP“
```
- **Kopfleiste**: Statuspunkt (türkis = aktiv). Bei `devMode` statt „Analyse aktiv“ gelb: „Im Dev-Modus wird
nicht gemessen.“ „Stand“ = Ladezeitpunkt der Daten. Bei mehreren Websites ein Auswahlfeld in der Leiste
(ersetzt das bisherige `fn-project`-Panel). Der Zeitraum-Schalter erscheint nur im Reiter Übersicht
(die Heatmap zeigt fest 30 Tage).
- **Reiter**: `role="tablist"`, Zustand bleibt beim Wechsel von Zeitraum oder Website erhalten
(`analyseSel.tab`), der URL-Hash bleibt `#analyse`. Auf dem Handy horizontal wischbar.
- **Das Wichtigste**: siehe 2.3. Unter 30 Seitenaufrufen im Zeitraum: „Sobald ein paar mehr Besuche da sind,
steht hier das Wichtigste über deine Website in zwei, drei Sätzen.“
- **Kennzahlen-Kacheln** (4): Seitenaufrufe, Besucher*, Kontakt-Aktionen*, Ø Zeit pro Seite* (Format `m:ss`,
darunter klein „Ø 64 % gescrollt“). Chip mit Veränderung (2.4). Mini-Verlauf aus `series` (Fläche + Linie,
ohne Achsen). Ohne Vergleich steht statt Chip „Vergleich ab TT.MM.“ (2.4).
- **Diagramm**: Seitenaufrufe pro Tag, aktuelle Linie türkis mit Fläche, Vorzeitraum gestrichelt grau (nach
Tagesindex ausgerichtet; nur wenn `previous.complete`). Legende „30 Tage“ / „Zeitraum davor“. Antippen oder
Überfahren eines Tages zeigt eine Blase „Sa., 19.09.: 371 Aufrufe (davor 210)“. SVG behält `<title>`.
- **Kontakt-Aktionen**: Summe im Kopf, Zeilen Anrufe (`tel`), E-Mails (`mail`), WhatsApp (`wa`), Links nach außen
(`out`) mit Symbol, Zahl und Veränderungs-Chip; Arten mit 0 in beiden Zeiträumen entfallen. Darunter die
Quote: „Auf 100 Besucher kommen 7,8 Kontakt-Aktionen (davor 6,5).“ (erst ab 30 Besuchern; Kontakt-Aktionen sind
Klicks, keine Personen – daher „pro 100 Besucher“, nicht „x % der Besucher“). Ohne Kontakte: „Noch keine
Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder
WhatsApp tippt.“
- **Ranglisten** in zwei Karten mit kleinem Umschalter: „Was angesehen wird“ (Seiten | Herkunft), „Wer zu Besuch
ist“ (Geräte | Länder). Zeile = Balken als Hintergrund des Namens, Zahl, Anteil in %. Erst 6 Zeilen, „Alle
anzeigen“ klappt bis 20 auf. `/` heißt „Startseite“. Herkunft mit lesbaren Namen für bekannte Hosts
(google.* → Google, bing.com → Bing, instagram.com → Instagram, facebook.com/m.facebook.com/l.facebook.com →
Facebook, duckduckgo.com → DuckDuckGo, ecosia.org → Ecosia), sonst der Host; `''` → „Direkt oder unbekannt“.
- **Datenbasis** (ersetzt Banner-Liste und Sternchen-Fußnote): Ring mit `consentRate`, Text „61 % haben dem
Banner zugestimmt (2.410 von 3.950 Entscheidungen). Seitenaufrufe zählen wir vollständig; Besucher,
Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.“ Ohne Entscheidungen: „Noch hat niemand über das
Banner entschieden.“
- **Heatmap-Karte**: kleines statisches Vorschaubild (CSS-Verlauf, keine echte Website), Satz, Knopf „Öffnen“
wechselt in den Reiter Heatmap.
### 2.2 Reiter Heatmap und Einstellungen, andere Zustände
- **Heatmap**: die bestehende Heatmap in Geräterahmen (`renderAnalyseHeat`, `geraete.js/.css`, iframes,
postMessage-Zähler, 20-s-Rückfall „In neuem Tab öffnen“) unverändert im Verhalten, optisch im Cockpit-Stil
(Seitenauswahl und Computer/Tablet-Schalter in einer Leiste). Die iframes laden erst, wenn der Reiter
geöffnet wird.
- **Einstellungen**: Karte „Status“ (aktiv seit `activatedAt`, Banner-Farbe als Farbfeld, Fassung
`avvVersion`), Karte „Unterlagen“ (Links AVV und Leistungsbeschreibung aus `legal`), Karte „Analyse
ausschalten“ (`renderAnalyseOff`, Texte und Ablauf unverändert).
- **Einschalten** (`renderAnalyseActivation`), **gesperrt**, **nicht verfügbar**, **unbekannt**, **noch nicht
freigeschaltet**: gleicher Cockpit-Rahmen (Kopfleiste ohne Zeitraum und Reiter). Alle Texte, Links, Häkchen,
Farbwahl und Banner-Vorschau bleiben **wörtlich** wie heute (Rechtsrunde 01./02.10.).
- **Laden**: Platzhalter-Kacheln in der Form der echten Kacheln statt „Lade…“. **Fehler 503**: Karte
„Auswertung gerade nicht erreichbar“ mit Knopf „Neu laden“.
### 2.3 Klartext-Regeln („Das Wichtigste“)
Reine Funktion `summarize(stats)` → `{ lead, items: [{ icon, text }] }` in `public/analyse-view.js`
(ohne DOM, Node-testbar). Zahlen deutsch formatiert. Nur bei `totals.pv >= 30`, sonst Leerzustand (2.1).
**Satz** (`lead`):
- Vergleich möglich (2.4): „Deine Website wurde in den letzten N Tagen **X-mal** aufgerufen – **Y % mehr** /
**Y % weniger** als in den N Tagen davor.“ Bei |Veränderung| < 5 %: „… – etwa so oft wie in den N Tagen
davor.“ Bei kleinen Zahlen (2.4) absolut: „… – **12 Aufrufe mehr** als in den N Tagen davor.“
- Kein Vergleich: „Deine Website wurde in den letzten N Tagen **X-mal** aufgerufen.“ Liegt `firstDay` im
Zeitraum: „Seit dem Start der Analyse am TT.MM. wurde deine Website **X-mal** aufgerufen.“
**Hinweise** (`items`, in dieser Reihenfolge, nur wenn die Bedingung erfüllt ist, höchstens 4):
1. Kontakte (`contacts >= 1`): „N-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).“ + bei
Vergleich und Vorwert ≥ 1: „Das sind 45 mehr als zuvor.“ / „… weniger …“ / „Genauso oft wie zuvor.“
Bei 0 Kontakten und `visitors >= 30`: „Von den Besuchern mit Zustimmung hat noch niemand über die Website
angerufen oder geschrieben.“ (Kontakt-Aktionen zählen nur bei Zustimmung.)
2. Wochentag (mindestens 7 Tage mit Daten im Zeitraum und `pv >= 70`): Ø Aufrufe je Wochentag über die Tage
ab `firstDay`; stärkster Wochentag, wenn sein Schnitt ≥ 1,2 × Gesamtschnitt: „Samstag ist im Schnitt dein
stärkster Tag.“ Zusätzlich Spitzentag, wenn ≥ 1,8 × Tagesschnitt: „Spitzentag war Sa., 19.09. mit 371
Aufrufen.“ (beides in einem Hinweis, wenn beides zutrifft).
3. Herkunft (erster Eintrag in `refs` mit Host, Anteil ≥ 20 % der Herkunfts-Summe): „Die meisten, die über
einen Link kamen, kommen von Google (880).“
4. Gerät (`m` oder `d` ≥ 55 % der Geräte-Summe): „66 % der Aufrufe kommen vom Handy.“ / „… vom Computer.“
5. Seite (`pages[0]`): „Am meisten angesehen: die Startseite, danach /preise (1.310).“ bzw. „Am meisten
angesehen: /preise (1.310 Aufrufe).“
### 2.4 Vergleich und Veränderungs-Chips
- Vergleich möglich, wenn `previous.complete` und `previous.totals.pv >= 20`. Sonst kein Chip; Kacheln zeigen
„Vergleich ab TT.MM.“ mit TT.MM. = `firstDay` + 2 × range Tage; ohne `firstDay` gar nichts. Fehlt
`previous` ganz (alter Collector), wie „kein Vergleich“ ohne Datum.
- Funktion `delta(cur, prev, { kind })` → `{ dir: 'up'|'down'|'flat', text, aria }`:
- Zählwerte: Prozent (ganzzahlig, „▲ 14 %“), wenn `prev >= 100` und `cur >= 100`; sonst absolut („▲ 12“,
„▼ 3“). Gleich → „± 0“ (`flat`). |Prozent| < 1 → `flat` „± 0 %“.
- Sekunden: absolut, „▲ 6 s“ / „▼ 1:05“ (ab 60 s als m:ss).
- `aria`: „plus 14 Prozent gegenüber dem Zeitraum davor“ usw. Farbe nie alleiniger Träger (Pfeile ▲▼).
- Farben: mehr = türkis, weniger = rosé, gleich = grau. Bewusst **keine** Wertung „gut/schlecht“ (weniger
Zeit pro Seite kann gut sein).
### 2.5 Optik („Cockpit“)
- Alles unter einem Wurzelelement `.an` mit eigenen CSS-Variablen; außerhalb ändert sich nichts.
Akzent `--an-acc: #2dd4bf`, stark `#5eead4`, weich `rgba(45,212,191,.1)`; weniger `#fda4af`;
Karten `rgba(14,16,20,.92)`, Rand `rgba(255,255,255,.09)`, Radius 16 px, Abstände 14 px.
- Kartentitel einheitlich: 13 px, fett, Großbuchstaben mit Sperrung, gedämpft; Zahlen `tabular-nums`.
- „Das Wichtigste“: Verlauf türkis → violett, türkiser Rand, Satz 21 px.
- Desktop: Raster wie Entwurf A (4 Kacheln; Diagramm 1,65 : 1 Kontakte; 2 × 2 Karten). Diagramm und
Kontaktkarte gleich hoch, das Diagramm füllt die Höhe.
- Handy (≤ 880 px, App-Ansicht): Kopfleiste zweizeilig, Zeitraum-Schalter volle Breite, Kacheln 2 × 2, Hinweise
einspaltig, alle Karten untereinander. Keine waagrechte Seiten-Scrollleiste.
- `prefers-reduced-motion`: keine Übergänge.
## 3. Code-Aufteilung
`public/funktionen.js` (980 Zeilen) wird entlastet:
- **`public/analyse.js`** (neu): gesamte Analyse-Oberfläche – `initAnalyse`, `renderAnalyseActivation`,
`renderAnalyseOff`, Übersicht, Heatmap, Einstellungen. `app.js` lädt `/analyse.js` für die Ansicht `analyse`.
- **`public/fn-util.js`** (neu): `esc`, `safeUrl`, `request` – bisher lokal in `funktionen.js`, jetzt von
beiden Modulen importiert.
- **`public/analyse-view.js`**: bekommt die reinen Funktionen `summarize`, `delta`, `fmtDuration`,
`contactRate`, `compareFrom`, `refName`, `weekdayStats` sowie `analyseOf`, `analyseState`,
`pickAnalyseProject` (bisher in `funktionen.js`, von der Kachel und der Analyse gebraucht).
- **`public/analyse.css`** (neu, in `dashboard.html` eingebunden): alle `.an`-Regeln; die alten
`.an-*`-Regeln in `funktionen.css` entfallen, soweit sie nur die Analyse betreffen.
- Kachel „Analyse“ auf der Funktionen-Seite bleibt in `funktionen.js` (unverändert im Verhalten).
- Bestehende Element-IDs, die Tests und Abläufe nutzen, bleiben: `#analyse-body`, `#an-stage`, `#an-preview`,
`#an-avv`, `#an-on`, `#an-error`, `#an-off-*`, `#an-heat-page`.
## 4. Tests
- **Collector** (`analytics/test/stats.test.js`): `previous` mit richtigen Tagen und Summen; `complete`
false/true je nach `firstDay`; `firstDay` aus `daily` und Rohdaten; `series` mit visitors/contacts/avgSec;
`avgSec`/`avgScroll` null ohne en; Vorzeitraum über eine Zeitumstellung (Ende Oktober) lückenlos und ohne
Überlappung; nichts doppelt (verdichtet + live). Vertragstest `test/analyticsContract.test.js` um die neuen
Felder ergänzen.
- **Portal, rein** (`test/analyseInsights.test.js`, neu): `summarize` für Wachstum, Rückgang, gleich, kleine
Zahlen, ohne Vergleich, neu gestartet, zu wenig Daten, ohne Kontakte, Wochentag/Spitzentag, Herkunft, Gerät,
höchstens 4 Hinweise; `delta` (Prozent/absolut/flat/Sekunden/aria); `compareFrom`; `contactRate`;
`refName`; HTML-Maskierung aller Werte, die aus Daten kommen (Seitenpfade, Hosts).
- **Portal, bestehend**: `funktionenTiles.test.js` und `analyseView.test.js` auf `analyse.js`/`fn-util.js`
umstellen; alle bisherigen Text-Prüfungen (Einschalten, Ausschalten) laufen unverändert weiter.
`analyseRoutes.test.js`: `activatedAt` in der Ansicht.
- **Browser** (`test/ui/analyse.ui.mjs`, erweitert; Playwright mit gemockter API): Desktop 1440 und Handy 402 ×
Zustände aktiv (volle Daten), aktiv neu (ohne Vergleich), wenig Daten, Dev-Modus, Einschalten, fehlende
Angaben, 503; Reiterwechsel; Heatmap-iframes erst nach Reiterwechsel; keine `pageerror`; keine waagrechte
Scrollleiste. Screenshots nach `/root/reports/analyse-ueberarbeitung-2026-10-04/screens/`.
- Gesamte Suiten: `npm test` (Portal) und `analytics: npm test` grün; Node 20 (Portal) und Node 24 (Collector).
## 5. Livegang
1. Nutzer pusht `feature/analyse-uebersicht` nach `main` (mein Push nach `main` ist im Auto-Modus gesperrt).
2. `deploy.sh` startet Portal und – weil `analytics/` geändert – den Collector neu. Keine Schema-Änderung,
keine `.env`-Änderung.
3. Prüfung durch mich: Collector `/a/health`, Stats-Abruf einer echten aktiven Website im Container
(neue Felder vorhanden, alte unverändert), Portal-Bundle enthält `analyse.js`, Ansicht lädt ohne Fehler.
4. Nutzer testet live mit einer Website mit aktiver Analyse.
Rückweg: Portal und Collector sind gegenseitig abwärtskompatibel (alte Oberfläche ignoriert neue Felder, neue
Oberfläche kommt ohne `previous` aus); Zurückrollen = Revert-Commit auf `main`.
## Nicht in diesem Projekt
Besucher-Details, Website-Check, Bericht per Mail (U1); Änderungen an Messskript, Banner und Rechtstexten (U4);
WOMS; Anpassen der Banner-Farbe nach dem Einschalten; Export.
+297
View File
@@ -0,0 +1,297 @@
/* HTML-Bausteine der Analyse-Uebersicht ("Cockpit"). Reine String-Builder ohne DOM und ohne Netz; analyse.js setzt sie ein, analyse.css stylt sie.
Jeder Wert aus Daten (Pfade, Hosts, Projektnamen, Tage, Links) laeuft durch esc(). Texte: Spec 2.1-2.4.
Vertrag fuer analyse.js und analyse.css (Praefix an-, Zustaende als Klasse "on" oder Attribut):
Kopf .an-top > .an-site[data-state=on|warn|off] (.an-status-dot, .an-site-name | select#an-project.an-pick, .an-status "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr") + .an-seg[role=group] button[data-range=7|30|90][aria-pressed] (.on)
Reiter .an-tabs[role=tablist] > button.an-tabbtn[role=tab][data-tab=uebersicht|heatmap|einstellungen][aria-selected] (.on), aria-controls="an-stage"
Wurzel .an-ov (Raster, Abstand 14 px) enthaelt: .an-sum, .an-kpis, .an-row2, 2x .an-pair, .an-credit
Wichtigste .an-sum > h3.an-eyebrow, p.an-lead (darin <b>), ul.an-points > li > span.an-point-ico (svg.an-ico) + span; Leerzustand: .an-sum.an-sum-empty
Kacheln .an-kpis > 4x .an-card.an-kpi[data-kpi=pv|visitors|contacts|time] > span.an-k (span.an-star), b.an-v, small.an-sub, .an-cmp (span.an-delta.up|down|flat[role=img][aria-label], span.an-vs), svg.an-spark (path.an-spark-area, path.an-spark-line)
Diagramm .an-card.an-chartbox > .an-head (h3, .an-legend > span.an-leg > i.an-leg-line[.an-leg-prev]) + .an-chartwrap > svg.an-chart (line.an-grid, text.an-axis, path.an-area, path.an-prev nur mit Vergleich, path.an-line,
circle.an-dot[data-i][data-day][data-pv][data-prev] fuer jeden Tag, per CSS erst bei Fokus sichtbar) + div.an-tip[hidden] (Blase; Text aus chartTipText)
Kontakte .an-card.an-contacts > .an-head (h3, b.an-sum-n) + ul.an-crows > li.an-crow[data-kind=tel|mail|wa|out] (span.an-cico, span.an-cname, b, span.an-delta) + p.an-quote; Leertext: p.an-empty
Ranglisten section.an-card.an-rank[data-rank=seen|who][data-rank-active=<Reiter>] > h3, .an-ltabs[role=group] > button.an-ltab[data-rank-tab][aria-pressed] (.on), ul.an-lrows > li.an-lr
(span.an-lab > i.an-fill[style=width:N%] + span.an-name[title], b, em), button.an-more[data-rank-more] ("Alle anzeigen", nur bei mehr als 6 Zeilen und nicht aufgeklappt)
Datenbasis .an-card.an-basis > svg.an-ring (circle.an-ring-bg, circle.an-ring-fg, text.an-ring-n) + p.an-basis-text > span.an-basis-main
Heatmap .an-card.an-teaser > .an-teaser-row > .an-mini-heat + p + button.an-btn[data-tab=heatmap] (data-tab auch auf dem Reiterknopf: Handler auf .an-tabs bzw. .an-teaser einschraenken)
Einstell. .an-ov.an-settings > .an-card.an-status-card (dl.an-dl, span.an-swatch) + .an-card.an-docs (ul > li > a) + section.an-card#an-off (leer, fuellt renderAnalyseOff)
Laden .an-ov.an-skeleton[aria-busy] mit .an-sk-Platzhaltern (.an-sk > i); .an-sr = nur fuer Screenreader sichtbar (muss in analyse.css stehen)
Fehler section.an-card.an-error[role=alert] > p + button.an-btn#an-retry */
import { esc } from './fn-util.js'
import { fmtNum, fmtDuration, periodLabel, shortDay, dayLabel, compareFrom, delta, contactRate, refName, summarize, chartModel, barRows, countryName, flagEmoji, DEVICE_LABELS, bannerColor } from './analyse-view.js'
const RANGES = [7, 30, 90]
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
const round = (n) => Math.round(n * 10) / 10
const list = (v) => (Array.isArray(v) ? v : [])
// Symbole wie ICONS in funktionen.js: 24er Raster, nur Striche
const PHONE = '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>'
const ICONS = {
contact: PHONE,
tel: PHONE,
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
wa: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/>',
out: '<path d="M7 7h10v10M7 17 17 7"/>',
day: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
source: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
device: '<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M11 18h2"/>',
page: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h6"/>',
}
const ico = (name) => `<svg class="an-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ''}</svg>`
// Veraenderungs-Chip aus delta(); ohne Wert nichts
const chip = (d) => (d ? `<span class="an-delta ${d.dir}" role="img" aria-label="${esc(d.aria)}">${esc(d.text)}</span>` : '')
const rateDe = (n) => n.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/* --- Kopfleiste und Reiter, Spec 2.1 --- */
const STATE_TEXT = { disabled: 'Analyse ist abgeschaltet', unknown: 'Status gerade nicht abrufbar' }
// Statuszeile: aktiv (mit "Heute bisher" aus stats.today und Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand
function statusOf(project, devMode, stamp, today) {
if (devMode) return { s: 'warn', text: 'Im Dev-Modus wird nicht gemessen.' }
const a = project?.analyse
const state = typeof a?.state === 'string' ? a.state : 'active'
if (state === 'active') {
const pv = isNum(today?.pv) ? today.pv : null // alter Collector ohne today: kein "Heute bisher"
// jeder Teil bricht nur als Ganzes in die naechste Zeile (kein "Stand:" allein am Zeilenende)
const parts = ['Analyse aktiv', pv === null ? '' : `Heute bisher: ${fmtNum(pv)} ${pv === 1 ? 'Aufruf' : 'Aufrufe'}`, stamp ? `Stand: ${stamp} Uhr` : '']
return { s: 'on', text: parts.filter(Boolean).map((t) => `<span class="an-st">${t}</span>`).join(' · ') }
}
return { s: 'off', text: Object.hasOwn(STATE_TEXT, state) ? STATE_TEXT[state] : a.eligible === false ? 'Analyse nicht verfügbar' : 'Analyse ist noch ausgeschaltet' }
}
/** Kopfleiste. usable: Websites mit Analyse (Auswahlfeld erst ab 2), stamp: Uhrzeit "19:42" des Ladens, today: stats.today (heute bisher). */
export function headerHtml({ project, usable, range, showRange, devMode, stamp, today } = {}) {
const sites = list(usable)
const st = statusOf(project, devMode, stamp ? esc(stamp) : '', today)
const current = RANGES.includes(Number(range)) ? Number(range) : 30
const name = sites.length > 1
? `<select id="an-project" class="an-pick" aria-label="Website wählen">${sites.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project?.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>`
: `<b class="an-site-name">${esc(project?.projectName)}</b>`
const seg = showRange
? `<div class="an-seg" role="group" aria-label="Zeitraum">${RANGES.map((r) => `<button type="button"${r === current ? ' class="on"' : ''} data-range="${r}" aria-pressed="${r === current}">${r} Tage</button>`).join('')}</div>`
: ''
return `<div class="an-top"><div class="an-site" data-state="${st.s}"><span class="an-status-dot" aria-hidden="true"></span><div>${name}<small class="an-status">${st.text}</small></div></div>${seg}</div>`
}
const TABS = [['uebersicht', 'Übersicht'], ['heatmap', 'Heatmap'], ['einstellungen', 'Einstellungen']]
export function tabsHtml(tab) {
const active = TABS.some(([k]) => k === tab) ? tab : 'uebersicht'
return `<div class="an-tabs" role="tablist" aria-label="Bereiche der Analyse">${TABS.map(([k, label]) => `<button type="button" role="tab" class="an-tabbtn${k === active ? ' on' : ''}" id="an-tab-${k}" data-tab="${k}" aria-selected="${k === active}" aria-controls="an-stage">${label}</button>`).join('')}</div>`
}
/* --- 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 = `<h3 class="an-eyebrow">Das Wichtigste${period ? ` · ${esc(period)}` : ''}</h3>`
if (!s) return `<section class="an-sum an-sum-empty">${head}<p class="an-lead">Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.</p></section>`
// lead und items[].html liefert summarize bereits maskiert
const points = s.items.length ? `<ul class="an-points">${s.items.map((i) => `<li><span class="an-point-ico">${ico(i.icon)}</span><span>${i.html}</span></li>`).join('')}</ul>` : ''
return `<section class="an-sum">${head}<p class="an-lead">${s.lead}</p>${points}</section>`
}
/** 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 `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${area}"/><path class="an-spark-line" d="${line}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
}
// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null.
// 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)}<span class="an-vs">vs. ${esc(k.vs)}</span>` : !cmp.ok && cmp.from ? `<span class="an-vs">Vergleich ab ${esc(cmp.from)}</span>` : ''
return `<div class="an-card an-kpi" data-kpi="${k.key}"><span class="an-k">${k.label}${k.star ? '<span class="an-star">*</span>' : ''}</span><b class="an-v">${esc(k.value)}</b>${k.sub ? `<small class="an-sub">${esc(k.sub)}</small>` : ''}<div class="an-cmp">${foot}</div>${sparkHtml(trend(stats?.series, field[k.key], k.key === 'time'))}</div>`
}
return `<div class="an-kpis">${tiles.map(tile).join('')}</div>`
}
/* --- 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) => `<div class="an-head"><h3>Seitenaufrufe pro Tag</h3>${legend}</div>`
if (!series.length) return `<section class="an-card an-chartbox">${head('')}<p class="an-empty">Noch keine Daten.</p></section>`
// 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 = `<div class="an-legend"><span class="an-leg"><i class="an-leg-line"></i>${days} Tage</span>${hasPrev ? '<span class="an-leg"><i class="an-leg-line an-leg-prev"></i>Zeitraum davor</span>' : ''}</div>`
const dots = m.points.map((p, i) => `<circle class="an-dot" data-i="${i}" cx="${p.x}" cy="${p.y}" r="3" data-day="${esc(p.day)}" data-pv="${p.pv}"${p.prev === null ? '' : ` data-prev="${p.prev}"`}/>`).join('')
return `<section class="an-card an-chartbox">${head(legend)}<div class="an-chartwrap"><svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title"><title id="an-chart-title">${esc(title)}</title>`
+ m.yTicks.map((k) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${k.y}" y2="${k.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${k.y}" dy="0.35em" text-anchor="end">${esc(k.label)}</text>`).join('')
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 9}" text-anchor="middle">${esc(l.label)}</text>`).join('')
+ `<path class="an-area" d="${m.area}"/>${hasPrev ? `<path class="an-prev" d="${m.prevLine}"/>` : ''}<path class="an-line" d="${m.line}"/>${dots}</svg><div class="an-tip" hidden></div></div></section>`
}
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 = `<div class="an-head"><h3>Kontakt-Aktionen</h3><b class="an-sum-n">${fmtNum(total)}</b></div>`
if (total < 1) return `<section class="an-card an-contacts">${head}<p class="an-empty">Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.</p></section>`
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 `<li class="an-crow" data-kind="${key}"><span class="an-cico">${ico(key)}</span><span class="an-cname">${label}</span><b>${fmtNum(cur)}</b>${chip(before && delta(cur, was))}</li>`
}).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 ? '' : `<p class="an-quote">Auf 100 Besucher kommen ${rateDe(rate)} Kontakt-Aktionen${wasRate === null ? '' : ` (davor ${rateDe(wasRate)})`}.</p>`
return `<section class="an-card an-contacts">${head}${rows ? `<ul class="an-crows">${rows}</ul>` : ''}${quote}</section>`
}
/* --- 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 ? '&lt; 1 %' : `${Math.round(pct)} %`
}
const all = barRows(items, NAMERS[active])
const row = (r) => {
const flag = active === 'countries' && flagEmoji(r.key) ? `<span class="an-flag" aria-hidden="true">${flagEmoji(r.key)}</span> ` : ''
return `<li class="an-lr"><span class="an-lab"><i class="an-fill" style="width:${r.pct}%" aria-hidden="true"></i><span class="an-name" title="${esc(r.label)}">${flag}${esc(r.label)}</span></span><b>${fmtNum(r.count)}</b><em>${share(r.count)}</em></li>`
}
const tabs = def.tabs.map(([k, label]) => `<button type="button" class="an-ltab${k === active ? ' on' : ''}" data-rank-tab="${k}" aria-pressed="${k === active}">${label}</button>`).join('')
const rows = all.length ? `<ul class="an-lrows">${all.slice(0, expanded ? LONG : SHORT).map(row).join('')}</ul>` : '<p class="an-empty">Noch keine Daten.</p>'
const more = all.length > SHORT && !expanded ? '<button type="button" class="an-more" data-rank-more>Alle anzeigen</button>' : ''
return `<section class="an-card an-rank" data-rank="${card}" data-rank-active="${active}"><h3>${def.title}</h3><div class="an-ltabs" role="group" aria-label="Ansicht wählen">${tabs}</div>${rows}${more}</section>`
}
/* --- 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 = `<svg class="an-ring" viewBox="0 0 74 74" aria-hidden="true"><circle class="an-ring-bg" cx="37" cy="37" r="30" fill="none" stroke-width="9"/>${rate > 0 ? `<circle class="an-ring-fg" cx="37" cy="37" r="30" fill="none" stroke-width="9" stroke-linecap="round" stroke-dasharray="${round(rate * full)} ${round(full)}" transform="rotate(-90 37 37)"/>` : ''}<text class="an-ring-n" x="37" y="42" text-anchor="middle">${decided > 0 ? `${fmtNum(pct)} %` : '–'}</text></svg>`
return `<section class="an-card an-basis"><h3>Datenbasis</h3><div class="an-basis-row">${ring}<p class="an-basis-text"><span class="an-basis-main">${main}</span> Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.</p></div></section>`
}
export function heatTeaserHtml() {
return '<section class="an-card an-teaser"><h3>Heatmap</h3><div class="an-teaser-row"><div class="an-mini-heat" aria-hidden="true"></div><p>Sieh auf deiner echten Website, wo geklickt wird – am Computer, Tablet und Handy.</p><button type="button" class="an-btn" data-tab="heatmap">Öffnen</button></div></section>'
}
/** Platzhalter in der Form der echten Kacheln, solange die Auswertung laedt. */
export function skeletonHtml() {
const card = (cls) => `<div class="an-card ${cls} an-sk" aria-hidden="true"><i></i><i></i><i></i></div>`
return `<div class="an-ov an-skeleton" aria-busy="true"><span class="an-sr" role="status">Auswertung wird geladen</span><div class="an-sum an-sk" aria-hidden="true"><i></i><i></i></div><div class="an-kpis">${card('an-kpi').repeat(4)}</div><div class="an-row2">${card('an-chartbox')}${card('an-contacts')}</div><div class="an-pair">${card('an-rank')}${card('an-rank')}</div></div>`
}
export function errorHtml(message) {
return `<section class="an-card an-error" role="alert"><p>${esc(message)}</p><button type="button" class="an-btn" id="an-retry">Neu laden</button></section>`
}
/* --- 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', `<span class="an-swatch" style="background:${color}" aria-hidden="true"></span> ${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]) => `<li><a href="${esc(url)}" target="_blank" rel="noopener">${label}</a></li>`)
return `<div class="an-ov an-settings"><section class="an-card an-status-card"><h3>Status</h3><dl class="an-dl">${rows.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('')}</dl></section>`
+ `${links.length ? `<section class="an-card an-docs"><h3>Unterlagen</h3><ul>${links.join('')}</ul></section>` : ''}<section class="an-card" id="an-off"></section></div>`
}
/* --- Zusammenbau der Uebersicht --- */
export function overviewHtml(stats) {
return `<div class="an-ov">${summaryHtml(stats)}${kpisHtml(stats)}<div class="an-row2">${chartHtml(stats)}${contactsHtml(stats)}</div>`
+ `<div class="an-pair">${rankCardHtml('seen', stats)}${rankCardHtml('who', stats)}</div><div class="an-pair">${basisHtml(stats)}${heatTeaserHtml()}</div>`
+ '<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p></div>'
}
+268 -7
View File
@@ -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 // Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1
export function contrastOk(hex) { export function contrastOk(hex) {
@@ -53,22 +55,33 @@ export function shortDay(day) {
return m ? `${m[2]}.${m[1]}.` : String(day || '') return m ? `${m[2]}.${m[1]}.` : String(day || '')
} }
/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */ /** 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 } = {}) { 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 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 innerW = width - pad.l - pad.r
const innerH = height - pad.t - pad.b 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 x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2)
const y = (v) => pad.t + innerH - (innerH * v) / top 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 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 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 line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ')
const baseY = round(pad.t + innerH) const baseY = round(pad.t + innerH)
const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : '' 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) { function round(n) {
@@ -90,3 +103,251 @@ export function controllerLines(controller) {
const c = controller || {} const c = controller || {}
return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean) 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 (<b>), 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 = `<b>${fmtNum(pv)}-mal</b>`
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}<b>${size} ${more ? 'mehr' : 'weniger'}</b> 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 }
}
+295
View File
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path d='M2 4.5l4 4 4-4' fill='none' stroke='white' stroke-opacity='.7' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>") 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; }
}
+518
View File
@@ -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 = `<p class="muted">${esc(err.message)}</p>` }
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 = '<div class="an"><section class="an-card an-state"><p class="an-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</p></section></div>'
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 = `<div class="an">${headHtml(project, usable, { showRange: active && tab === 'uebersicht' })}${active ? tabsHtml(tab) : ''}<div id="an-stage"${active ? ` role="tabpanel" aria-labelledby="an-tab-${tab}"` : ''}></div></div>`
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 = '<section class="an-card an-state"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></section>'
else if (a.state === 'unknown') stage.innerHTML = '<section class="an-card an-state"><p>Die Daten konnten gerade nicht geladen werden.</p></section>'
else if (!a.eligible) stage.innerHTML = `<section class="an-card an-state"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` <button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>` : ''}</p></section>`
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 `
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
<p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p>
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
</div>`
}
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 `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
}
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 = `
<div class="an-card an-panel">
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p>
<p>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.</p>
<h4 class="an-h4">Farbe des Banners</h4>
<div class="an-colors">
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
</div>
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
<div id="an-preview"></div>
${controllerBlock(controller)}
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>
<p class="error hidden" id="an-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
</div>`
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 = '<div class="an-ov"><section class="an-card an-heat" id="an-heat"></section></div>'
const box = stage.querySelector('#an-heat')
if (!state.stats) {
box.innerHTML = '<h3>Heatmap</h3><p class="an-empty">Lade…</p>'
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 = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
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', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
} catch {
/* Text reicht */
}
}
function renderAnalyseHeat(box, projectId, pages) {
if (!box) return
if (!pages.length) {
box.innerHTML = '<h3>Heatmap</h3><p class="an-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
return
}
if (!heatListening) {
window.addEventListener('message', onHeatMessage)
heatListening = true
}
const big = heatSel.big === 't' ? 't' : 'd'
box.innerHTML = `
<div class="hs-head"><h3>Heatmap</h3></div>
<p>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.</p>
<div class="an-heat-bar">
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
<div class="an-seg hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button"${vp === big ? ' class="on"' : ''} data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
</div>
<div class="hs-stage" data-big="${big}">
<figure class="hs-dev hs-dev--big"></figure>
<figure class="hs-dev hs-dev--phone"></figure>
</div>`
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 = `
<h3>Analyse ausschalten</h3>
<p>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.</p>
<p class="error hidden" id="an-off-error" role="alert"></p>
<div class="an-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
const actions = box.querySelector('#an-off-actions')
const error = box.querySelector('#an-off-error')
box.querySelector('#an-off-ask').addEventListener('click', () => {
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
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 = `
<h3>Analyse ausgeschaltet</h3>
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
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')
}
})
})
}
+1 -1
View File
@@ -1917,7 +1917,7 @@ const viewLoaders = { chat: initChat }
const viewRefreshers = { const viewRefreshers = {
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))), funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-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'))), termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
} }
const loadedViews = new Set() const loadedViews = new Set()
+2 -1
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/kundenbereich.css"> <link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css"> <link rel="stylesheet" href="/chat-composer.css">
<link rel="stylesheet" href="/funktionen.css"> <link rel="stylesheet" href="/funktionen.css">
<link rel="stylesheet" href="/analyse.css">
<link rel="stylesheet" href="/termine.css"> <link rel="stylesheet" href="/termine.css">
</head> </head>
<body class="portal-page dashboard-page kb-page"> <body class="portal-page dashboard-page kb-page">
@@ -177,7 +178,7 @@
<div id="news-body"><p class="kb-empty">Lade…</p></div> <div id="news-body"><p class="kb-empty">Lade…</p></div>
</section> </section>
<!-- Analyse: Einschalten oder Auswertung (funktionen.js, initAnalyse) --> <!-- Analyse: Einschalten oder Auswertung (analyse.js, initAnalyse) -->
<section class="kb-view" id="view-analyse"> <section class="kb-view" id="view-analyse">
<header class="kb-head"> <header class="kb-head">
<h1>Analyse</h1> <h1>Analyse</h1>
+22
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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
}
-79
View File
@@ -137,82 +137,3 @@
.fn-tiles { grid-template-columns: minmax(0, 1fr); } .fn-tiles { grid-template-columns: minmax(0, 1fr); }
.fn-facts dt { min-width: 0; flex: 1 0 100%; } .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%; }
}
+2 -400
View File
@@ -3,8 +3,8 @@
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css. Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ 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 { esc, safeUrl, request } from '/fn-util.js'
import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.js'
import { ZU_VERTRAEGE } from './vertraege-link.js' import { ZU_VERTRAEGE } from './vertraege-link.js'
const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt 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', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'], ['combo', 'Foto + Text', 'beides', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'],
] ]
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
function fmtDay(value) { function fmtDay(value) {
const d = new Date(value) const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return '' if (!value || Number.isNaN(d.getTime())) return ''
@@ -44,23 +40,6 @@ function berlinIsoDay(offsetDays = 0) {
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) 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 --- */ /* ------------------------------------------------------------ Zustand --- */
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten // Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
const selection = { projectId: '' } const selection = { projectId: '' }
@@ -120,15 +99,9 @@ function newsTile(projects, price = PRICE) {
const ZU_VERTRAEGE_LINK = `<button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>` const ZU_VERTRAEGE_LINK = `<button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>`
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ /* 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 DEV_HINT = 'Im Dev-Modus wird nicht gemessen.'
const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.' 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) { function analyseTile(projects) {
const target = pickAnalyseProject(projects) const target = pickAnalyseProject(projects)
const a = target && analyseOf(target) const a = target && analyseOf(target)
@@ -656,374 +629,3 @@ function renderEditor(stage, project) {
sync() sync()
return loadList() 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 = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>'
return
}
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
analyseSel.projectId = project.projectId
root.innerHTML = `
${usable.length > 1 ? `
<div class="fn-project kb-panel">
<label for="an-project">Website</label>
<select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
</div>` : ''}
<div id="an-stage"></div>`
root.querySelector('#an-project')?.addEventListener('change', (ev) => {
analyseSel.projectId = ev.target.value
initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
})
const stage = root.querySelector('#an-stage')
const a = analyseOf(project)
const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
else if (a.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</p></div>'
else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ${ZU_VERTRAEGE_LINK}` : ''}</p></div>`
else renderAnalyseActivation(stage, project, reload)
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
return `
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
<p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p>
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
</div>`
}
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 `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
}
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 = `
<div class="kb-panel an-panel">
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p>
<p>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.</p>
<h4 class="an-h4">Farbe des Banners</h4>
<div class="an-colors">
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
</div>
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
<div id="an-preview"></div>
${controllerBlock(controller)}
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>
<p class="error hidden" id="an-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
</div>`
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 '<p class="nw-empty">Noch keine Daten.</p>'
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 `
<svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title">
<title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))}</title>
${m.yTicks.map((t) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${t.y}" y2="${t.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${t.y + 4}" text-anchor="end">${esc(t.label)}</text>`).join('')}
${m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')}
<path class="an-area" d="${m.area}"/>
<path class="an-line" d="${m.line}"/>
${m.points.length <= 31 ? m.points.map((p) => `<circle class="an-dot" cx="${p.x}" cy="${p.y}" r="3"><title>${esc(p.day)}: ${esc(nf.format(p.pv))}</title></circle>`).join('') : ''}
</svg>`
}
function barList(title, items, labelOf, { html = false } = {}) {
const rows = barRows(items, labelOf)
return `
<section class="kb-panel an-list">
<h3>${esc(title)}</h3>
${rows.length ? `<ul class="an-rows">${rows.map((r) => `
<li><div class="an-row-top"><span class="an-row-label">${html ? r.label : esc(r.label)}</span><b>${esc(nf.format(r.count))}</b></div><div class="an-bar"><i style="width:${r.pct}%"></i></div></li>`).join('')}</ul>` : '<p class="nw-empty">Noch keine Daten.</p>'}
</section>`
}
const COUNTRY_ROW = (key) => `${flagEmoji(key) ? `<span aria-hidden="true">${flagEmoji(key)}</span> ` : ''}${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 = `
<div class="an-range" role="tablist" aria-label="Zeitraum">${[7, 30, 90].map((r) => `<button type="button" role="tab" class="an-tab${r === range ? ' active' : ''}" aria-selected="${r === range}" data-range="${r}">${r} Tage</button>`).join('')}</div>
<div id="an-data"><p class="kb-empty">Lade…</p></div>
<section class="kb-panel an-heat" id="an-heat"></section>
<section class="kb-panel an-off" id="an-off"></section>
<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p>`
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 = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
return
}
if (!stage.isConnected || myRequest !== statsRequestId) return
const t = stats.totals || {}
const rate = Math.round((Number(t.consentRate) || 0) * 100)
const kpi = (label, value) => `<div class="an-kpi"><span>${label}</span><b>${esc(value)}</b></div>`
const b = stats.banner || {}
data.innerHTML = `
<div class="an-kpis">${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} %`)}</div>
<section class="kb-panel an-chart-box"><h3>Seitenaufrufe pro Tag</h3>${lineChart(stats.series)}</section>
<div class="an-grid-lists">
${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]))}
</div>
<p class="an-note">${esc(KPI_NOTE)}</p>`
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 = `<div class="hs-frame"></div><figcaption class="hs-cap" aria-live="polite">${esc(heatCaption('loading'))}</figcaption>`
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', ` <a class="kb-link" href="${esc(url)}" target="_blank" rel="noopener">In neuem Tab öffnen</a>`)
} catch {
/* Text reicht */
}
}
function renderAnalyseHeat(box, projectId, pages) {
if (!box) return
if (!pages.length) {
box.innerHTML = '<h3>Heatmap</h3><p class="nw-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
return
}
if (!heatListening) {
window.addEventListener('message', onHeatMessage)
heatListening = true
}
const big = heatSel.big === 't' ? 't' : 'd'
box.innerHTML = `
<div class="hs-head">
<h3>Heatmap</h3>
<div class="hs-switch" role="group" aria-label="Großes Gerät">${['d', 't'].map((vp) => `<button type="button" class="an-tab${vp === big ? ' active' : ''}" data-vp="${vp}" aria-pressed="${vp === big}">${HEAT_DEVICES[vp].label}</button>`).join('')}</div>
</div>
<p>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.</p>
<div class="an-heat-row">
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
</div>
<div class="hs-stage" data-big="${big}">
<figure class="hs-dev hs-dev--big"></figure>
<figure class="hs-dev hs-dev--phone"></figure>
</div>`
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 = `
<h3>Analyse ausschalten</h3>
<p>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.</p>
<p class="error hidden" id="an-off-error" role="alert"></p>
<div class="nw-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
const actions = box.querySelector('#an-off-actions')
const error = box.querySelector('#an-off-error')
box.querySelector('#an-off-ask').addEventListener('click', () => {
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
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 = `
<h3>Analyse ausgeschaltet</h3>
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
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')
}
})
})
}
+2 -2
View File
@@ -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 /* 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; funktionen.js (Kachel Analyse, Kachel Terminplaner; baut daraus HTML), analyse.js (Analyse-Seite; baut daraus HTML) und termine.js (Hinweis
baut daraus ein Element). Reine Daten ohne Import: beide laden das Modul relativ, funktionen.js behält seine zwei absoluten Importe. 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). */ 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' }) export const ZU_VERTRAEGE = Object.freeze({ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' })
+18 -4
View File
@@ -1,6 +1,20 @@
import { config } from '../config.js' 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 authHeader = req.get('Authorization') || ''
const match = authHeader.match(/^Bearer\s+(.+)$/i) const match = authHeader.match(/^Bearer\s+(.+)$/i)
if (!match) { if (!match) {
@@ -29,11 +43,11 @@ export async function requireAppwriteStaff(req, res, next) {
} }
if (!response.ok) { 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')) { if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' }) return res.status(403).json({ error: deniedMessage })
} }
req.appwriteUser = data req.appwriteUser = data
+1 -1
View File
@@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) {
if (origin && allowedOrigins.includes(origin)) { if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS) 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') { if (req.method === 'OPTIONS') {
return res.sendStatus(204) return res.sendStatus(204)
} }
+3 -2
View File
@@ -2,12 +2,13 @@
import { Router } from 'express' import { Router } from 'express'
import { config } from '../../config.js' import { config } from '../../config.js'
import { getDocument } from '../../services/appwriteAdmin.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 { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js'
import { getFeatureDoc } from '../../services/siteFeatures.js' import { getFeatureDoc } from '../../services/siteFeatures.js'
const router = Router() const router = Router()
router.use(requireAppwriteStaff) // Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
function sendError(res, err) { function sendError(res, err) {
const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500 const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500
+1 -1
View File
@@ -121,7 +121,7 @@ async function unterlagenAlsAnhang() {
// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut. // je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut.
async function adminView(customerId) { async function adminView(customerId) {
const docs = await listCustomerFeatureDocs(customerId) const docs = await listCustomerFeatureDocs(customerId)
const projects = await listCustomerFeatures(customerId, { docs }) const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true })
const abo = aboMemo() const abo = aboMemo()
for (const p of projects) { for (const p of projects) {
for (const key of FEATURE_KEYS) { for (const key of FEATURE_KEYS) {
+11 -2
View File
@@ -1,10 +1,13 @@
import { Router } from 'express' 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 { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
const router = Router() const router = Router()
router.use(requireAppwriteStaff) // Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
router.post('/sync-repos', async (_req, res) => { router.post('/sync-repos', async (_req, res) => {
try { try {
@@ -35,6 +38,12 @@ router.get('/repo-readme', async (req, res) => {
} }
try { 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) const readme = await fetchRepoReadme(repoFullName)
if (!readme) { if (!readme) {
return res.json({ ok: true, found: false, repoFullName }) return res.json({ ok: true, found: false, repoFullName })
+3 -2
View File
@@ -2,7 +2,7 @@
import { Router } from 'express' import { Router } from 'express'
import { config } from '../../config.js' import { config } from '../../config.js'
import { getDocument, updateDocument } from '../../services/appwriteAdmin.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 { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js' import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
import * as runnerClient from '../../services/previewRunnerClient.js' import * as runnerClient from '../../services/previewRunnerClient.js'
@@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js'
import { repoHasDevScript } from '../../services/previewDevScript.js' import { repoHasDevScript } from '../../services/previewDevScript.js'
const router = Router() 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 BUSY = 'Server ausgelastet, versuche es gleich noch mal'
const MODES = ['auto', 'build', 'dev', 'off'] const MODES = ['auto', 'build', 'dev', 'off']
+3 -2
View File
@@ -2,11 +2,12 @@ import { Router } from 'express'
import { config } from '../../config.js' import { config } from '../../config.js'
import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { listDocuments, Query } from '../../services/appwriteAdmin.js'
import { servicesCatalog } from '../../services/projectKinds.js' import { servicesCatalog } from '../../services/projectKinds.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
const router = Router() const router = Router()
router.use(requireAppwriteStaff) // Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
const CHUNK = 100 const CHUNK = 100
+10 -3
View File
@@ -6,14 +6,15 @@ import {
} from '../../services/giteaAdmin.js' } from '../../services/giteaAdmin.js'
import { assignProject, reconcileProject } from '../../services/projectAssignment.js' import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
import { completeAssignment } from '../../services/projectKindAssign.js' import { completeAssignment } from '../../services/projectKindAssign.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
const router = Router() const router = Router()
router.use(requireAppwriteStaff) // Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
router.use(requireAppwriteTeam)
router.post('/create-from-template', async (req, res) => { 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) { if (!repoName && !displayName) {
return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' }) return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' })
@@ -27,6 +28,8 @@ router.post('/create-from-template', async (req, res) => {
customerId, customerId,
ticketId, ticketId,
owner, owner,
kind,
staffName: req.appwriteUser?.name || req.appwriteUser?.email || '',
}) })
return res.status(201).json({ ok: true, ...result }) return res.status(201).json({ ok: true, ...result })
} catch (err) { } catch (err) {
@@ -38,6 +41,10 @@ router.post('/create-from-template', async (req, res) => {
// Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite // Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite
router.patch('/:id', async (req, res) => { router.patch('/:id', async (req, res) => {
const { projectName, subdomain, projectType } = req.body || {} 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 { try {
const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType }) const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType })
return res.json({ ok: true, ...result }) return res.json({ ok: true, ...result })
+2 -1
View File
@@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => {
const projects = await listDocuments(config.collections.websiteProjects, queries) const projects = await listDocuments(config.collections.websiteProjects, queries)
const project = projects[0] 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' }) return res.json({ allowed: false, reason: 'no_project' })
} }
if (!isPreviewProjectReady(project)) { if (!isPreviewProjectReady(project)) {
+3 -1
View File
@@ -76,6 +76,7 @@ async function analyseView(customerId, projectId) {
avvVersion: config.analyse.avvVersion, avvVersion: config.analyse.avvVersion,
legal: legalPaths(config.analyse.avvVersion), legal: legalPaths(config.analyse.avvVersion),
controller, 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.' }) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
} }
const customerId = getSessionCustomerId(req) const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId) await assertOwnProject(customerId, projectId, { forNewOrder: true })
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') { if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') {
throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt') 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.' }) return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
} }
const { projectId, accentColor, consent } = req.body || {} const { projectId, accentColor, consent } = req.body || {}
await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true })
const { doc } = await activateAnalyse({ const { doc } = await activateAnalyse({
customerId: getSessionCustomerId(req), customerId: getSessionCustomerId(req),
projectId: analyseProjectId(projectId), projectId: analyseProjectId(projectId),
+5 -2
View File
@@ -14,7 +14,8 @@ const router = Router()
async function loadOwnProject(req, res, next) { async function loadOwnProject(req, res, next) {
const customerId = getSessionCustomerId(req) const customerId = getSessionCustomerId(req)
const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null) 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' }) return res.status(404).json({ error: 'Projekt nicht gefunden' })
} }
req.project = project req.project = project
@@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => {
const projects = await listDocuments(config.collections.websiteProjects, [ const projects = await listDocuments(config.collections.websiteProjects, [
Query.equal('customerId', customerId), Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'), 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, id: p.$id,
projectName: p.projectName || '', projectName: p.projectName || '',
subdomain: p.subdomain || '', subdomain: p.subdomain || '',
+32 -7
View File
@@ -1,16 +1,17 @@
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { Router } from 'express' import { Router } from 'express'
import { config } from '../../config.js' import { config } from '../../config.js'
import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js' import { upsertWebsiteProjectByRepo, getDocument, listDocuments, Query } from '../../services/appwriteAdmin.js'
import { import {
fetchPreviewConfig, fetchPreviewConfig,
defaultPreviewConfig, defaultPreviewConfig,
runDeploy, runDeploy,
foreignSubdomainBlock,
deployKeepsStatus, deployKeepsStatus,
buildPreviewUrl, buildPreviewUrl,
} from '../../services/previewDeploy.js' } from '../../services/previewDeploy.js'
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.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 { sendPushTimeMail } from '../../services/pushTimeMail.js'
import { ensureProjectTicket } from '../../services/ticketAdmin.js' import { ensureProjectTicket } from '../../services/ticketAdmin.js'
import { invalidateProjectCache } from '../../services/projectInsights.js' import { invalidateProjectCache } from '../../services/projectInsights.js'
@@ -107,18 +108,24 @@ router.post('/gitea', async (req, res) => {
} }
} }
const previewConfig = previewConfigFile || defaultPreviewConfig(name) 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 - // Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen -
// Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website. // Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website.
const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : '' const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : ''
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
let deployResult = { deployed: false, skipped: true } let deployResult = blocked || { deployed: false, skipped: true }
const configBranch = previewConfig.branch || 'main' const configBranch = previewConfig.branch || 'main'
if (hasPreview && previewConfig.enabled !== false && branch === configBranch) { if (hasPreview && previewConfig.enabled !== false && branch === configBranch) {
try { try {
deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha }) deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha, subdomainChecked: true })
} catch (deployErr) { } catch (deployErr) {
console.error('[webhook] deploy failed:', deployErr.message) console.error('[webhook] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: 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 payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase()
const projectPayload = { 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, templateName: previewConfig.templateName || config.gitea.templateRepo,
giteaRepoUrl: payloadNameStillCurrent giteaRepoUrl: payloadNameStillCurrent
? repo.html_url || `${config.gitea.baseUrl}/${current}` ? repo.html_url || `${config.gitea.baseUrl}/${current}`
@@ -161,6 +169,20 @@ router.post('/gitea', async (req, res) => {
} else if (hasPreview) { } else if (hasPreview) {
projectPayload.status = 'pending' projectPayload.status = 'pending'
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : '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 { } else {
projectPayload.status = 'synced' projectPayload.status = 'synced'
} }
@@ -177,7 +199,10 @@ router.post('/gitea', async (req, res) => {
// (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht // (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht
// dieselbe ticketId-Aufloesung doppelt anstoesst. // dieselbe ticketId-Aufloesung doppelt anstoesst.
let gitWorksheet = null 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 <name>" neben dem, das im Zuordnen-Assistenten gewaehlt wird.
if (commitSha && commitSha !== '0000000000000000000000000000000000000000' && !isSystemPusher(pusher)) {
gitWorksheet = await withLocks( gitWorksheet = await withLocks(
['project:' + project.$id], ['project:' + project.$id],
async () => { async () => {
+26 -13
View File
@@ -317,19 +317,32 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) {
} }
const now = new Date().toISOString() const now = new Date().toISOString()
return adminFetch(collectionPath(config.collections.websiteProjects), { try {
method: 'POST', return await adminFetch(collectionPath(config.collections.websiteProjects), {
body: { method: 'POST',
...payload, body: {
customerId: payload.customerId ?? '', ...payload,
ticketId: payload.ticketId ?? '', customerId: payload.customerId ?? '',
status: payload.status ?? 'pending', ticketId: payload.ticketId ?? '',
provisioningStatus: payload.provisioningStatus ?? 'pending', status: payload.status ?? 'pending',
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, provisioningStatus: payload.provisioningStatus ?? 'pending',
createdAt: now, projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
documentId: ID.unique(), 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() { export async function verifyDatabaseAccess() {
+1 -1
View File
@@ -198,7 +198,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
portalPassword = normalizedPassword portalPassword = normalizedPassword
} }
} else if (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 error.status = 400
throw error throw error
} }
+210 -73
View File
@@ -5,6 +5,7 @@ import {
Query, Query,
getDocument, getDocument,
updateDocument, updateDocument,
deleteDocument,
} from './appwriteAdmin.js' } from './appwriteAdmin.js'
import { import {
runDeploy, runDeploy,
@@ -17,7 +18,8 @@ import {
import { giteaFetch } from './giteaClient.js' import { giteaFetch } from './giteaClient.js'
import { slugify } from './slug.js' import { slugify } from './slug.js'
import { withLocks } from './keyedLock.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 // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
// Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht // 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 * 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) - * jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte
* ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem * 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 * 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 * parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die
* innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere * innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere
* gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung). * 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() { export async function importNewRepos() {
return withLocks(['import'], async () => { return withLocks(['import'], async () => {
@@ -64,39 +70,75 @@ export async function importNewRepos() {
]) ])
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling). // Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
const existingNames = new Set( const docsByName = new Map()
docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean) for (const d of docs) {
) const key = String(d.repoFullName || '').toLowerCase()
if (key && !docsByName.has(key)) docsByName.set(key, d)
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 })
})
} }
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() const repos = await listAllAccessibleRepos()
let created = 0 let created = 0
let updated = 0 let updated = 0
const failed = []
for (const repo of repos) { for (const repo of repos) {
const repoFullName = repo.full_name const repoFullName = repo.full_name
@@ -115,25 +158,38 @@ export async function syncReposToAppwrite() {
]) ])
const payload = { const payload = {
projectName: repo.description || repo.name || repoFullName.split('/').pop(),
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || repoFullName.split('/').pop(), giteaRepoName: repo.name || repoFullName.split('/').pop(),
repoFullName, repoFullName,
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet. // In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
archived: Boolean(repo.archived), archived: Boolean(repo.archived),
} }
const repoTitle = repo.description || repo.name || repoFullName.split('/').pop()
if (!existing[0]) { if (!existing[0]) {
payload.status = 'synced' payload.projectName = repoTitle
payload.status = repo.archived ? 'archived' : 'synced'
created += 1 created += 1
} else { } 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 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) { 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 templateOwner = config.gitea.templateOwner
const templateRepo = config.gitea.templateRepo const templateRepo = config.gitea.templateRepo
const targetOwner = owner || config.gitea.defaultOwner const targetOwner = owner || config.gitea.defaultOwner
@@ -162,8 +218,11 @@ export async function generateRepoFromTemplate({ owner, repoName }) {
body: JSON.stringify({ body: JSON.stringify({
owner: targetOwner, owner: targetOwner,
name: repoName, name: repoName,
private: false, // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04). Portal und Preview-Runner
description: `Website-Projekt ${repoName}`, // 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, git_content: true,
}), }),
}) })
@@ -271,6 +330,37 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
return previewConfig 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({ export async function createProjectFromTemplate({
repoName, repoName,
displayName, displayName,
@@ -278,6 +368,8 @@ export async function createProjectFromTemplate({
customerId = '', customerId = '',
ticketId = '', ticketId = '',
owner, owner,
kind = '',
staffName = '',
}) { }) {
const targetOwner = owner || config.gitea.defaultOwner const targetOwner = owner || config.gitea.defaultOwner
const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain) const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain)
@@ -289,14 +381,22 @@ export async function createProjectFromTemplate({
if (!safeRepoName) { if (!safeRepoName) {
throw new Error('repoName oder displayName ist erforderlich') 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) { if (hasWebsite) {
await ensureSubdomainAvailable(safeSubdomain) await ensureSubdomainAvailable(safeSubdomain)
} }
await releaseLeftoverProjectName(`${targetOwner}/${safeRepoName}`)
const repo = await generateRepoFromTemplate({ const repo = await generateRepoFromTemplate({
owner: targetOwner, owner: targetOwner,
repoName: safeRepoName, repoName: safeRepoName,
description: displayName || safeRepoName,
}) })
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}` const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
@@ -324,7 +424,11 @@ export async function createProjectFromTemplate({
} }
const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : '' 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, customerId,
ticketId, ticketId,
projectName: previewConfig.displayName, projectName: previewConfig.displayName,
@@ -332,9 +436,24 @@ export async function createProjectFromTemplate({
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || safeRepoName, giteaRepoName: repo.name || safeRepoName,
repoFullName, 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, 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) ...(hasWebsite && deployKeepsStatus(deployResult)
? {} // Preview-Runner (C1): Status setzt der Verfolger ? {} // Preview-Runner (C1): Status setzt der Verfolger
: { : {
@@ -343,14 +462,14 @@ export async function createProjectFromTemplate({
? (deployResult.deployed ? 'ready' : 'deploy_failed') ? (deployResult.deployed ? 'ready' : 'deploy_failed')
: 'ready', : 'ready',
}), }),
}) }), { waitMs: 10000 })
return { return {
projectId: project.$id, projectId: project.$id,
repoFullName, repoFullName,
previewUrl, previewUrl,
subdomain: safeSubdomain, subdomain: safeSubdomain,
projectType: hasWebsite ? 'website' : 'repo', projectType: hasWebsite ? 'preview' : 'repo',
deploy: deployResult, deploy: deployResult,
} }
} }
@@ -494,7 +613,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : '' const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : ''
const updated = await updateDocument(config.collections.websiteProjects, projectId, { const updated = await updateDocument(config.collections.websiteProjects, projectId, {
projectName: nextName, projectName: nextName,
subdomain: nextSubdomain, subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate)
previewUrl, previewUrl,
projectType: nextType, projectType: nextType,
isPublic, isPublic,
@@ -550,30 +669,48 @@ export async function archiveRepo(projectId, archived = true) {
const repoFullName = doc.repoFullName || '' const repoFullName = doc.repoFullName || ''
let repoMissing = false let repoMissing = false
if (repoFullName.includes('/')) { // Gitea und Dokument unter der Repo-Sperre aendern - dieselbe nimmt der Import, der den
try { // Archiv-Stand aus Gitea spiegelt; sonst koennte er einen alten Stand zurueckschreiben.
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) const locks = repoFullName ? [repoLockKey(repoFullName)] : []
} catch (err) { let updated = await withLocks(locks, async () => {
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem if (repoFullName.includes('/')) {
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. try {
if (err.status === 404) { await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
repoMissing = true } catch (err) {
console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
} else { // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) if (err.status === 404) {
error.status = err.status || 500 repoMissing = true
throw error 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) { // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner
try { await removeDeploy(doc.subdomain) } catch (err) { // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden.
console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) // 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 { try {
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
@@ -583,19 +720,19 @@ export async function archiveRepo(projectId, archived = true) {
subdomain: doc.subdomain, subdomain: doc.subdomain,
displayName: doc.projectName, 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) { } catch (err) {
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) 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, { return { project: updated, archived: false }
archived: Boolean(archived),
...(archived ? { status: 'archived' } : {}),
updatedAt: new Date().toISOString(),
})
return { project: updated, archived: Boolean(archived) }
} }
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */ /** Repo-Basisdaten (Groesse in KB, Default-Branch). */
+1 -1
View File
@@ -52,7 +52,7 @@ export async function giteaFetch(path, options = {}) {
if (!response.ok) { if (!response.ok) {
let message = data?.message || `Gitea ${response.status}` let message = data?.message || `Gitea ${response.status}`
if (response.status === 409 && /same name already exists/i.test(message)) { 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) const error = new Error(message)
error.status = response.status error.status = response.status
+68
View File
@@ -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<string>} 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 = {}) { 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) { if (config.previewRunner.enabled) {
return runDeployViaRunner(repoFullName, branch, previewConfig, options) return runDeployViaRunner(repoFullName, branch, previewConfig, options)
} }
+4 -1
View File
@@ -149,7 +149,10 @@ export async function runRepoAssignJob({
const docs = await listDocuments(collection(), [Query.limit(5000)]) const docs = await listDocuments(collection(), [Query.limit(5000)])
for (const p of docs) { 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. // Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h.
const attempts = Number(p.assignMailAttempts || 0) const attempts = Number(p.assignMailAttempts || 0)
if (!p.assignMailSentAt && attempts < 3) { if (!p.assignMailSentAt && attempts < 3) {
+2
View File
@@ -59,6 +59,8 @@ function sameRepoFullName(a, b) {
export async function isInternalRepo(repoFullName) { export async function isInternalRepo(repoFullName) {
const lower = repoFullName.toLowerCase() const lower = repoFullName.toLowerCase()
if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true 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 hooks = await giteaFetch(`/repos/${repoFullName}/hooks`)
const portalUrl = portalWebhookUrl() const portalUrl = portalWebhookUrl()
+15 -6
View File
@@ -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 let project = null
try { try {
project = await getDocument(config.collections.websiteProjects, projectId) project = await getDocument(config.collections.websiteProjects, projectId)
@@ -80,6 +81,9 @@ export async function assertOwnProject(customerId, projectId) {
if (err.status !== 404) throw err if (err.status !== 404) throw err
} }
if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt') 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 return project
} }
@@ -149,14 +153,19 @@ export function listCustomerFeatureDocs(customerId) {
return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)]) 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 } = {}) { * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden.
const projects = await listDocuments(config.collections.websiteProjects, [ * 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.equal('customerId', customerId),
Query.orderDesc('$createdAt'), Query.orderDesc('$createdAt'),
Query.limit(1000), Query.limit(1000),
]) ])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id))
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
const result = [] const result = []
for (const p of projects) { for (const p of projects) {
const features = {} const features = {}
+404
View File
@@ -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, /<b>14 % mehr<\/b>/) // Satz aus summarize
assert.match(h, /Länderdaten: IP-Geolocation by <a href="https:\/\/db-ip\.com" target="_blank" rel="noopener">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, /<b class="an-v">612<\/b>/)
assert.equal(count(h, /<svg class="an-spark"/g), 1) // nur Seitenaufrufe haben eine Reihe (series hat nur pv)
})
test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => {
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 <b>6\.930-mal<\/b> aufgerufen/)
assert.equal(count(h, /<li>/g), 4); assert.match(h, /<svg class="an-ico"/)
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // alle Symbole der Hinweise sind belegt
assert.match(h, /178-mal hat dich jemand direkt kontaktiert, meist per Telefon \(96\)\./) // ohne die 10 Links nach außen
const few = summaryHtml(stats({ totals: { ...t, pv: 12 } }))
assert.match(few, /Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen\./)
assert.doesNotMatch(few, /<ul/)
assert.match(summaryHtml({ totals: { pv: 3 } }), /<h3 class="an-eyebrow">Das Wichtigste<\/h3>/) // ohne from/to kein Zeitraum
})
test('kpisHtml: Chips, Vorwerte, Mini-Verläufe, Zeit mit Scrolltiefe', () => {
const h = kpisHtml(full)
assert.equal(count(h, /class="an-card an-kpi"/g), 4)
assert.equal(count(h, /an-delta up/g), 3); assert.equal(count(h, /an-delta down/g), 1)
assert.match(h, /▲ 10 %/); assert.match(h, /▲ 31 %/); assert.match(h, /▼ 6 s/)
assert.match(h, /vs\. 2\.200/); assert.match(h, /vs\. 143/); assert.match(h, /vs\. 1:54/)
assert.match(h, /aria-label="plus 14 Prozent gegenüber dem Zeitraum davor"/)
assert.match(h, /Besucher<span class="an-star">\*<\/span>/)
assert.equal(count(h, /<svg class="an-spark"/g), 4)
// Verlauf der Zeit: Tage ohne Messung (null) fehlen im Verlauf, sie werden nicht als 0 gezeichnet
const noTime = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, avgSec: i === 0 ? null : 100 })) }))
assert.equal(count(noTime, /<svg class="an-spark"/g), 4)
const timeLine = (html) => /data-kpi="time".*?class="an-spark-line" d="([^"]*)"/s.exec(html)[1]
assert.match(timeLine(noTime), /^M6\.9 /) // der erste Tag (x = 0) fehlt, die Linie beginnt am zweiten
assert.equal(count(timeLine(noTime), /M/g), 1)
assert.doesNotMatch(timeLine(noTime), /M0 |L0 /)
assert.match(timeLine(kpisHtml(full)), /^M0 /) // vollständige Reihe: Linie ab dem ersten Tag
// Zählwerte behalten 0: ein Tag ohne Aufrufe ist ein echter Tiefpunkt
const zeroPv = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, pv: i === 0 ? 0 : 231 })) }))
assert.match(/data-kpi="pv".*?class="an-spark-line" d="([^"]*)"/s.exec(zeroPv)[1], /^M0 36 /)
// Reihe ohne avgSec-Feld: kein Verlauf bei der Zeit
assert.equal(count(kpisHtml(stats({ series: full.series.map(({ avgSec, ...d }) => d) })), /<svg class="an-spark"/g), 3)
// keine Zeitmessung: Strich statt "0:00"
const none = kpisHtml(stats({ totals: { ...t, avgSec: null, avgScroll: null }, previous: { ...prev, totals: { ...prev.totals, avgSec: null } } }))
assert.match(none, /<b class="an-v">–<\/b>/); assert.doesNotMatch(none, /gescrollt|vs\. –/)
})
test('kpisHtml: neue Website zeigt "Vergleich ab 19.11."', () => {
const h = kpisHtml(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } }))
assert.equal(count(h, /Vergleich ab 19\.11\./g), 4)
assert.doesNotMatch(h, /an-delta|vs\./)
const k7 = kpisHtml(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } }))
assert.match(k7, /Vergleich ab 12\.10\./)
// vollständiger Vorzeitraum, aber unter 20 Aufrufen: weder Chip noch Datum
const tiny = kpisHtml(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 5 } } }))
assert.doesNotMatch(tiny, /an-delta|vs\.|Vergleich ab/)
})
test('kpisHtml: Richtungen – weniger und gleich', () => {
const less = kpisHtml(stats({ totals: { ...t, pv: 5000 } }))
assert.match(less, /<span class="an-delta down" role="img" aria-label="minus 18 Prozent gegenüber dem Zeitraum davor">▼ 18 %<\/span>/)
const same = kpisHtml(stats({ totals: { ...t, contacts: 143 } }))
assert.match(same, /an-delta flat/); assert.match(same, /± 0/)
})
test('sparkHtml: Fläche und Linie, nichts bei weniger als 2 Werten, kein NaN', () => {
const h = sparkHtml([1, 5, 3, 8])
assert.match(h, /<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none"/)
assert.match(h, /class="an-spark-area"/); assert.match(h, /class="an-spark-line"/)
assert.equal(sparkHtml([4]), ''); assert.equal(sparkHtml([]), ''); assert.equal(sparkHtml(undefined), '')
assert.doesNotMatch(sparkHtml([0, 0, 0]), /NaN|Infinity/)
assert.doesNotMatch(sparkHtml([3, null, undefined, 'x']), /NaN|undefined|null/)
})
test('sparkHtml: Lücken (null) unterbrechen die Linie statt als 0 zu gelten', () => {
const line = (h) => /class="an-spark-line" d="([^"]*)"/.exec(h)[1]
const area = (h) => /class="an-spark-area" d="([^"]*)"/.exec(h)[1]
// Wert 0 liegt auf der Grundlinie (y = 36); eine Lücke darf dort keinen Punkt erzeugen
const gap = sparkHtml([4, 6, null, 6, 8])
assert.equal(count(line(gap), /M/g), 2) // zwei Abschnitte: Tage 0-1 und 3-4
assert.doesNotMatch(line(gap), / 36( |$)/)
assert.equal(count(area(gap), /Z/g), 2) // Fläche je Abschnitt
assert.doesNotMatch(line(gap), /(^|[ML])100 /) // der Tag mit Lücke (x = 100) hat keinen Punkt
assert.match(line(sparkHtml([null, 5, 7, 9])), /^M66\.7 /) // Lücke am Anfang: Linie beginnt später, nicht bei 0
assert.equal(sparkHtml([null, null, null]), ''); assert.equal(sparkHtml([null, 4]), '') // unter 2 Messwerten nichts
assert.doesNotMatch(sparkHtml([4, null, 6]), /NaN|undefined|null/)
// alle Werte vorhanden: ein Abschnitt wie bisher
assert.equal(count(line(sparkHtml([1, 5, 3, 8])), /M/g), 1)
})
test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte mit Daten', () => {
const h = chartHtml(full)
assert.match(h, /<title id="an-chart-title">Seitenaufrufe pro Tag vom 05\.09\. bis 04\.10\., insgesamt 7\.\d{3}<\/title>/)
assert.match(h, /class="an-area"/); assert.match(h, /class="an-line"/); assert.match(h, /class="an-prev"/)
assert.match(h, /30 Tage/); assert.match(h, /Zeitraum davor/)
assert.equal(count(h, /class="an-dot"/g), 30)
assert.match(h, /data-i="14" /); assert.match(h, /data-day="2026-09-19"/); assert.match(h, /data-pv="371"/); assert.match(h, /data-prev="203"/)
assert.match(h, /class="an-tip"/)
// ohne Vergleich: keine gestrichelte Linie, keine zweite Legende, kein data-prev
const nc = chartHtml(stats({ previous: { ...prev, complete: false } }))
assert.doesNotMatch(nc, /class="an-prev"|Zeitraum davor|data-prev/)
assert.match(nc, /30 Tage/)
// ganz ohne Daten
assert.match(chartHtml({ series: [] }), /Noch keine Daten\./)
assert.doesNotMatch(chartHtml(zero), /NaN|Infinity|undefined|null/)
})
test('chartHtml: Achsenbeschriftung hat Platz – y-Rand wächst mit der Stellenzahl, x-Beschriftungen überlappen nicht', () => {
const padOf = (html) => Number(/class="an-grid" x1="([\d.]+)"/.exec(html)[1])
const series = (pv, n = 30) => days('2026-09-05', n, pv).map((d) => ({ ...d, visitors: 1, contacts: 0, avgSec: 1 }))
const none = { previous: { ...prev, complete: false } }
// Skala 500 (3 Zeichen), 5.000 (4), 20.000 (5), 50 (2): Platz für die Ziffern der y-Beschriftung
assert.equal(padOf(chartHtml(stats({ series: series(300), ...none }))), 49)
assert.equal(padOf(chartHtml(stats({ series: series(2400), ...none }))), 64)
assert.equal(padOf(chartHtml(stats({ series: series(12000), ...none }))), 78)
assert.equal(padOf(chartHtml(stats({ series: series(30), ...none }))), 40)
// y-Beschriftung steht rechtsbündig 6 Einheiten vor dem Raster und ist an der Gitterlinie zentriert
const h = chartHtml(stats({ series: series(2400), ...none }))
assert.match(h, /<text class="an-axis" x="58" y="[\d.]+" dy="0\.35em" text-anchor="end">5000<\/text>/)
// x-Beschriftungen: nie näher als 100 Einheiten (Platz für "29.09." in der größten Schrift), letzter Tag bleibt
for (const n of [7, 30, 90]) {
const xs = [...chartHtml(stats({ series: series(300, n), ...none })).matchAll(/class="an-axis" x="([\d.]+)" y="[\d.]+" text-anchor="middle"/g)].map((m) => Number(m[1]))
assert.ok(xs.length >= 3, `range ${n}`)
assert.ok(xs.every((x, i) => !i || x - xs[i - 1] >= 100), `range ${n}: ${xs}`)
assert.equal(xs[xs.length - 1], 616) // 640 - rechter Rand 24: der letzte Tag
}
})
test('chartTipText: Blase mit und ohne Vorwert', () => {
assert.equal(chartTipText('2026-09-19', 371, 210), 'Sa., 19.09.: 371 Aufrufe (davor 210)')
assert.equal(chartTipText('2026-09-19', '371', '210'), 'Sa., 19.09.: 371 Aufrufe (davor 210)') // Werte aus data-Attributen sind Strings
assert.equal(chartTipText('2026-09-19', 371), 'Sa., 19.09.: 371 Aufrufe')
assert.equal(chartTipText('2026-09-19', 371, null), 'Sa., 19.09.: 371 Aufrufe')
assert.equal(chartTipText('2026-09-19', 1, 0), 'Sa., 19.09.: 1 Aufruf (davor 0)')
assert.equal(chartTipText('2026-09-19', 6930, 6080), 'Sa., 19.09.: 6.930 Aufrufe (davor 6.080)')
})
test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => {
const h = contactsHtml(full)
assert.match(h, /<b class="an-sum-n">188<\/b>/)
assert.equal(count(h, /aria-hidden="true"><\/svg>/g), 0) // jede Zeile hat ihr Symbol
assert.equal(count(h, /<svg class="an-ico"/g), 4)
for (const label of ['Anrufe', 'E-Mails', 'WhatsApp', 'Links nach außen']) assert.match(h, new RegExp(label))
assert.match(h, /data-kind="tel"[^]*?<b>96<\/b>[^]*?▲ 16/) // 96 vs 80: beide unter 100 => absolut statt Prozent
assert.match(h, /data-kind="out"[^]*?<b>10<\/b>[^]*?▲ 10/) // 10 vs 0
// Art ohne Kontakte, in beiden Zeiträumen 0: Zeile entfällt
const noOut = contactsHtml(stats({ contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'out', count: 0 }], totals: { ...t, contacts: 148 }, previous: { ...prev, contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }] } }))
assert.doesNotMatch(noOut, /data-kind="out"|data-kind="wa"|WhatsApp|Links nach außen/)
assert.match(noOut, /data-kind="tel"/); assert.match(noOut, /data-kind="mail"/)
// Vorzeitraum hatte WhatsApp, jetzt 0: Zeile bleibt mit ▼
const lost = contactsHtml(stats({ contacts: [{ key: 'tel', count: 96 }], totals: { ...t, contacts: 96 } }))
assert.match(lost, /data-kind="wa"[^]*?<b>0<\/b>[^]*?▼ 23/)
// ohne Vergleich: keine Chips, Zeilen nur mit Kontakten
const nc = contactsHtml(stats({ previous: { ...prev, complete: false } }))
assert.doesNotMatch(nc, /an-delta|davor/); assert.match(nc, /data-kind="tel"/)
// Leertext
const none = contactsHtml(zero)
assert.match(none, /Noch keine Kontakt-Aktionen\. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt\./)
assert.doesNotMatch(none, /data-kind|an-quote/)
// 0 Kontakte, aber der Vorzeitraum hatte welche: weiter Leertext (Kopfzahl 0)
assert.match(contactsHtml(stats({ totals: { ...t, contacts: 0 }, contacts: [] })), /Noch keine Kontakt-Aktionen\./)
})
test('contactsHtml: Quote pro 100 Besucher, mit und ohne Vorwert, erst ab 30 Besuchern', () => {
assert.match(contactsHtml(full), /<p class="an-quote">Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\.<\/p>/)
const nc = contactsHtml(stats({ previous: { ...prev, complete: false } }))
assert.match(nc, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen\./)
const fewPrev = contactsHtml(stats({ previous: { ...prev, totals: { ...prev.totals, visitors: 10 } } }))
assert.match(fewPrev, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen\./) // Vorquote unsicher => ohne "davor"
assert.doesNotMatch(contactsHtml(stats({ totals: { ...t, visitors: 29, contacts: 3 }, contacts: [{ key: 'tel', count: 3 }] })), /an-quote/)
assert.match(contactsHtml(stats({ totals: { ...t, visitors: 400, contacts: 28 } })), /kommen 7,0 Kontakt-Aktionen/) // immer eine Nachkommastelle
})
test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => {
const h = rankCardHtml('seen', stats({ pages: [{ key: '/<img src=x>', count: 9 }] }))
assert.match(h, /&lt;img src=x&gt;/); assert.doesNotMatch(h, /<img src=x>/)
const pages = rankCardHtml('seen', full)
assert.match(pages, /Startseite/); assert.doesNotMatch(pages, />\/</)
assert.match(pages, /<b>3\.200<\/b><em>46 %<\/em>/) // 3200 von 6930 Aufrufen (die Liste zeigt nur 5410 davon)
assert.match(pages, /data-rank="seen" data-rank-active="pages"/)
assert.match(pages, /<h3>Was angesehen wird<\/h3>/)
assert.match(pages, /data-rank-tab="pages" aria-pressed="true"/); assert.match(pages, /data-rank-tab="refs" aria-pressed="false"/)
// Herkunft
const refs = rankCardHtml('seen', full, { tab: 'refs' })
assert.match(refs, /Google/); assert.match(refs, /Direkt oder unbekannt/); assert.match(refs, /Instagram/); assert.match(refs, /Facebook/)
assert.doesNotMatch(refs, /google\.com|l\.facebook/); assert.match(refs, /data-rank-active="refs"/)
// 6 Zeilen, "Alle anzeigen" nur bei mehr als 6; expanded bis 20
const many = stats({ pages: Array.from({ length: 25 }, (_, i) => ({ key: `/seite-${i}`, count: 100 - i })) })
const six = rankCardHtml('seen', many)
assert.equal(count(six, /class="an-lr"/g), 6); assert.match(six, /data-rank-more[^>]*>Alle anzeigen</)
const open = rankCardHtml('seen', many, { expanded: true })
assert.equal(count(open, /class="an-lr"/g), 20); assert.doesNotMatch(open, /Alle anzeigen/)
assert.doesNotMatch(rankCardHtml('seen', stats({ pages: Array.from({ length: 6 }, (_, i) => ({ key: `/s${i}`, count: 9 })) })), /Alle anzeigen/)
assert.equal(count(rankCardHtml('seen', stats({ pages: Array.from({ length: 6 }, (_, i) => ({ key: `/s${i}`, count: 9 })) })), /class="an-lr"/g), 6)
assert.match(rankCardHtml('seen', many), /an-fill" style="width:100%"/) // größter Wert = volle Breite
})
test('rankCardHtml: Geräte, Länder mit Flagge, ungültiger Reiter, leere Liste, Anteil unter 1 %', () => {
const dev = rankCardHtml('who', full)
assert.match(dev, /<h3>Wer zu Besuch ist<\/h3>/); assert.match(dev, /Handy/); assert.match(dev, /Computer/); assert.match(dev, /Tablet/)
assert.match(dev, /<b>4\.590<\/b><em>66 %<\/em>/)
assert.match(rankCardHtml('who', stats({ devices: [{ key: 'x', count: 2 }, { key: 'constructor', count: 1 }] })), /Unbekannt/)
const land = rankCardHtml('who', full, { tab: 'countries' })
assert.match(land, /🇩🇪/); assert.match(land, /Deutschland/); assert.match(land, /Österreich/); assert.match(land, /Unbekannt/)
assert.match(rankCardHtml('who', full, { tab: 'pages' }), /data-rank-active="devices"/) // Reiter einer anderen Karte => erster Reiter
assert.match(rankCardHtml('seen', stats({ pages: [] })), /Noch keine Daten\./)
assert.equal(rankCardHtml('nope', full), '')
const tiny = rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 10000 }, { key: '/b', count: 1 }] }))
assert.match(tiny, /<em>&lt; 1 %<\/em>/)
assert.doesNotMatch(rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 0 }] })), /NaN|Infinity/)
// Nenner: Seiten, Länder, Geräte relativ zu max(Summe der Liste, Seitenaufrufe); Herkunft nur zur Summe der Liste
const part = stats({ totals: { ...t, pv: 1000 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }], countries: [{ key: 'DE', count: 250 }], devices: [{ key: 'm', count: 500 }], refs: [{ key: 'google.com', count: 30 }, { key: '', count: 10 }] })
assert.match(rankCardHtml('seen', part), /<b>300<\/b><em>30 %<\/em>/) // 300 von 1000, nicht von 400 (75 %)
assert.match(rankCardHtml('who', part, { tab: 'countries' }), /<b>250<\/b><em>25 %<\/em>/)
assert.match(rankCardHtml('who', part), /<b>500<\/b><em>50 %<\/em>/)
assert.match(rankCardHtml('seen', part, { tab: 'refs' }), /<b>30<\/b><em>75 %<\/em>/) // Herkunft: 30 von 40
// Liste größer als die Gesamtzahl (Zählungen laufen auseinander): die Summe, nie mehr als 100 %
const over = stats({ totals: { ...t, pv: 100 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }] })
assert.match(rankCardHtml('seen', over), /<b>300<\/b><em>75 %<\/em>/)
// ohne totals (alter Collector): Summe der Liste
assert.match(rankCardHtml('seen', { pages: [{ key: '/a', count: 3 }, { key: '/b', count: 1 }] }), /<em>75 %<\/em>/)
// Länder-Schlüssel maskiert
const bad = rankCardHtml('who', stats({ countries: [{ key: '<b>', count: 3 }] }), { tab: 'countries' })
assert.match(bad, /Unbekannt/); assert.doesNotMatch(bad, /<b><|<b>&/); assert.doesNotMatch(bad, /an-flag/)
})
test('basisHtml: Ring, Sätze mit und ohne Entscheidungen', () => {
const h = basisHtml(full)
assert.match(h, /<text class="an-ring-n"[^>]*>61 %<\/text>/); assert.match(h, /class="an-ring-fg"/)
assert.match(h, /Seitenaufrufe zählen wir vollständig; Besucher, Kontakt-Aktionen und Zeit pro Seite \(\*\) nur bei Zustimmung\./)
const none = basisHtml(zero)
assert.match(none, /Noch hat niemand über das Banner entschieden\./); assert.doesNotMatch(none, /an-ring-fg|haben dem Banner zugestimmt|NaN/)
assert.match(none, />–<\/text>/)
assert.match(basisHtml(stats({ banner: { shown: 1, accept: 1, reject: 0 }, totals: { ...t, consentRate: 1 } })), /\(1 von 1 Entscheidung\)/)
assert.doesNotMatch(basisHtml({}), /NaN|undefined|null/)
})
test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => {
const a = project()
const b = { projectId: 'p2', projectName: 'Café "Zum <Bären>"' }
const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42', today: { day: '2026-10-05', pv: 1234, visitors: 80, contacts: 3 } })
assert.doesNotMatch(one, /an-project|<select/); assert.match(one, /<b class="an-site-name">Boulderhalle Überhang<\/b>/)
assert.match(one, /<small class="an-status"><span class="an-st">Analyse aktiv<\/span> · <span class="an-st">Heute bisher: 1\.234 Aufrufe<\/span> · <span class="an-st">Stand: 19:42 Uhr<\/span><\/small>/)
assert.match(one, /<button type="button" class="on" data-range="30" aria-pressed="true">30 Tage<\/button>/)
assert.equal(count(one, /data-range="/g), 3); assert.match(one, /data-range="7"[^>]*aria-pressed="false"/)
const two = headerHtml({ project: b, usable: [a, b], range: 7, showRange: false, devMode: false, stamp: '' })
assert.match(two, /<select id="an-project"/); assert.match(two, /<option value="p2" selected>Café &quot;Zum &lt;Bären&gt;&quot;<\/option>/)
assert.match(two, /<option value="p1">Boulderhalle Überhang<\/option>/)
assert.doesNotMatch(two, /data-range/); assert.doesNotMatch(two, /Zum <Bären>/); assert.doesNotMatch(two, /Stand:/)
assert.match(two, /Analyse aktiv/); assert.doesNotMatch(two, /Heute bisher/) // ohne today (alter Collector) entfällt der Teil
assert.match(headerHtml({ project: a, usable: [a], range: 365, showRange: true }), /class="on" data-range="30"/) // unbekannter Zeitraum => 30
// Dev-Modus
const dev = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: true, stamp: '19:42' })
assert.match(dev, /Im Dev-Modus wird nicht gemessen\./); assert.doesNotMatch(dev, /Analyse aktiv|Stand:|Heute bisher/); assert.match(dev, /data-state="warn"/)
assert.match(one, /data-state="on"/)
// Name maskiert
const evil = headerHtml({ project: { projectId: 'x', projectName: '<img src=x onerror=alert(1)>' }, usable: [], range: 30, showRange: false })
assert.doesNotMatch(evil, /<img/); assert.match(evil, /&lt;img src=x onerror=alert\(1\)&gt;/)
const odd = headerHtml({ project: { projectId: 'p"1', projectName: 'A' }, usable: [{ projectId: 'p"1', projectName: 'A' }, { projectId: 'p2', projectName: 'B' }], stamp: '<b>x</b>', today: { pv: '<i>5</i>' } })
assert.match(odd, /value="p&quot;1" selected/); assert.match(odd, /Stand: &lt;b&gt;x&lt;\/b&gt; Uhr/); assert.doesNotMatch(odd, /<b>x|<i>/)
// Heute bisher: Einzahl, 0 und nur mit Zahl; ohne Uhrzeit nur der Zähler
const today = (pv, stamp = '') => headerHtml({ project: a, usable: [a], range: 30, showRange: false, stamp, today: { day: '2026-10-05', pv, visitors: 0, contacts: 0 } })
const plain = (h) => /<small class="an-status">(.*?)<\/small>/.exec(h)[1].replace(/<[^>]+>/g, '') // Text ohne die Umbruch-Spans
assert.equal(plain(today(1, '08:05')), 'Analyse aktiv · Heute bisher: 1 Aufruf · Stand: 08:05 Uhr')
assert.match(today(0, '08:05'), /Heute bisher: 0 Aufrufe/)
assert.equal(plain(today(42)), 'Analyse aktiv · Heute bisher: 42 Aufrufe')
assert.doesNotMatch(headerHtml({ project: a, usable: [a], today: {} }), /Heute bisher|NaN/) // today ohne pv: nichts behaupten
// Zustände ohne Messung zeigen kein "Heute bisher"
assert.doesNotMatch(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [], today: { pv: 5 } }), /Heute bisher/)
// Zustände ohne Messung sagen nicht "aktiv"
const off = headerHtml({ project: project({ analyse: { state: 'off', eligible: true } }), usable: [], range: 30, showRange: false })
assert.doesNotMatch(off, /Analyse aktiv/); assert.match(off, /data-state="off"/)
assert.match(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [] }), /Analyse ist abgeschaltet/)
assert.match(headerHtml({ project: project({ analyse: { state: 'off', eligible: false } }), usable: [] }), /Analyse nicht verfügbar/)
assert.doesNotMatch(headerHtml({}), /undefined|null|NaN/)
})
test('tabsHtml: tablist, drei Reiter, aria-selected', () => {
const h = tabsHtml('heatmap')
assert.match(h, /role="tablist"/); assert.equal(count(h, /role="tab"/g), 3)
for (const k of ['uebersicht', 'heatmap', 'einstellungen']) assert.match(h, new RegExp(`data-tab="${k}"`))
assert.match(h, /data-tab="heatmap" aria-selected="true"/); assert.match(h, /data-tab="uebersicht" aria-selected="false"/)
assert.equal(count(h, /aria-selected="true"/g), 1)
assert.match(tabsHtml('quatsch'), /data-tab="uebersicht" aria-selected="true"/)
assert.match(h, />Übersicht<\/button>/); assert.match(h, />Heatmap<\/button>/); assert.match(h, />Einstellungen<\/button>/)
})
test('heatTeaserHtml: Knopf wechselt in den Reiter Heatmap', () => {
const h = heatTeaserHtml()
assert.match(h, /<button type="button" class="an-btn" data-tab="heatmap">Öffnen<\/button>/); assert.match(h, /<h3>Heatmap<\/h3>/)
assert.match(h, /an-mini-heat/)
})
test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {
const h = settingsHtml(project())
assert.match(h, /aktiv seit 02\.10\.2026/) // 22:30 UTC = 00:30 Berlin am 02.10.
assert.match(h, /class="an-swatch" style="background:#1d4ed8"/); assert.match(h, /#1d4ed8/)
assert.match(h, /Fassung 2026-10-02/)
assert.match(h, /<a href="\/rechtliches\/avv-website-analyse-2026-10-02\.html" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse<\/a>/)
assert.match(h, /<a href="\/rechtliches\/leistungsbeschreibung-website-analyse-2026-10-02\.html" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse<\/a>/)
assert.match(h, /<section class="an-card" id="an-off"><\/section>/)
// Farbe ungültig => #111111; kein Datum => ohne "seit"
const bad = settingsHtml(project({ analyse: { state: 'active', accentColor: 'red;"><x>', avvVersion: '2026-10-02', legal: {}, activatedAt: null } }))
assert.match(bad, /background:#111111/); assert.doesNotMatch(bad, /<x>|seit|<a /)
assert.match(bad, /id="an-off"/)
// zu helle Farbe: das Banner nutzt Schwarz, das Farbfeld zeigt dasselbe (bannerColor)
const light = settingsHtml(project({ analyse: { state: 'active', accentColor: '#ffee00', avvVersion: '2026-10-02', legal: {}, activatedAt: null } }))
assert.match(light, /class="an-swatch" style="background:#111111"/); assert.doesNotMatch(light, /#ffee00/i)
assert.match(settingsHtml(project({ analyse: { state: 'active', accentColor: '#1D4ED8' } })), /background:#1d4ed8/) // groß geschrieben => klein
// gefährliche Link-Ziele und Namen
const evil = settingsHtml(project({ analyse: { state: 'active', accentColor: '#fff', avvVersion: '<b>', legal: { avv: 'javascript:alert(1)', lb: '/x"onmouseover="y' }, activatedAt: 'kaputt' } }))
assert.doesNotMatch(evil, /javascript:|<b>|onmouseover="y/); assert.doesNotMatch(evil, /NaN|undefined|null|Invalid/)
assert.doesNotMatch(settingsHtml({ projectId: 'p' }), /NaN|undefined|null|Invalid/)
})
test('errorHtml und skeletonHtml', () => {
assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/)
assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /Auswertung gerade nicht erreichbar/); assert.match(errorHtml('x'), /role="alert"/); assert.match(errorHtml('x'), />Neu laden</)
assert.match(errorHtml('<img src=x>'), /&lt;img src=x&gt;/); assert.doesNotMatch(errorHtml('<img src=x>'), /<img/)
const sk = skeletonHtml()
assert.equal(count(sk, /class="an-card an-kpi an-sk"/g), 4); assert.match(sk, /aria-busy="true"/)
assert.doesNotMatch(sk, /Lade…|undefined|NaN/)
})
test('Alle Bausteine: Werte aus Daten werden maskiert (Pfade, Hosts, Tage, Länder)', () => {
const evil = '"><script>alert(1)</script>'
const s = stats({
pages: [{ key: evil, count: 5 }],
refs: [{ key: evil, count: 5 }],
countries: [{ key: evil, count: 5 }],
devices: [{ key: evil, count: 5 }],
series: [{ day: evil, pv: 5 }, { day: '2026-09-18', pv: 7 }],
previous: { ...prev, series: [{ pv: 3 }, { pv: 4 }] },
})
const all = overviewHtml(s) + rankCardHtml('seen', s, { tab: 'refs' }) + rankCardHtml('who', s, { tab: 'countries' }) + rankCardHtml('who', s) + chartHtml(s)
assert.doesNotMatch(all, /<script>/)
assert.match(all, /&lt;script&gt;/)
})
+289
View File
@@ -0,0 +1,289 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { delta, fmtDuration, fmtNum, dayLabel, periodLabel, compareFrom, contactRate, refName, weekdayStats, summarize, chartModel } from '../public/analyse-view.js'
// Testdaten: eine vollstaendige Statistik-Antwort mit 30 Tagen (Form wie analytics/src/stats.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 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: 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: days('2026-09-05', 30, 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 }],
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,
})
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(1004, 1000).dir, 'flat')
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)
assert.equal(delta(3, undefined), null)
assert.equal(delta('3', 4), null)
})
test('delta: Richtung und aria in Wörtern', () => {
assert.deepEqual(delta(5000, 6000), { dir: 'down', text: '▼ 17 %', aria: 'minus 17 Prozent gegenüber dem Zeitraum davor' })
assert.equal(delta(45, 33).aria, 'plus 12 gegenüber dem Zeitraum davor')
assert.equal(delta(30, 33).aria, 'minus 3 gegenüber dem Zeitraum davor')
assert.equal(delta(108, 114, { kind: 'sec' }).aria, 'minus 6 Sekunden gegenüber dem Zeitraum davor')
assert.equal(delta(200, 135, { kind: 'sec' }).aria, 'plus 1 Minute 5 Sekunden gegenüber dem Zeitraum davor')
assert.equal(delta(240, 120, { kind: 'sec' }).aria, 'plus 2 Minuten gegenüber dem Zeitraum davor')
})
test('fmtDuration, dayLabel, periodLabel, fmtNum', () => {
assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–')
assert.equal(fmtDuration(NaN), '–'); assert.equal(fmtDuration(-3), '–'); assert.equal(fmtDuration(undefined), '–')
assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.')
assert.equal(dayLabel('2026-09-20'), 'So., 20.09.')
assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.')
assert.equal(periodLabel('2026-03-01', '2026-06-30'), '1. März – 30. Juni')
assert.equal(periodLabel('', ''), '')
assert.equal(periodLabel('2026-09-05', ''), '')
assert.equal(fmtNum(6930), '6.930')
assert.equal(fmtNum(0), '0')
assert.equal(fmtNum(1234567), '1.234.567')
})
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: '19.11.' }) // range 30: +60 Tage (2 × range, der erste Tag mit vollem Vorzeitraum)
assert.deepEqual(compareFrom(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })), { ok: false, from: '12.10.' }) // range 7: +14 Tage
assert.deepEqual(compareFrom(stats({ firstDay: '', previous: { ...prev, complete: false } })), { ok: false, from: '' })
assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' })
assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false)
assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 20 } } })).ok, true)
})
test('contactRate und refName', () => {
assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null)
assert.equal(contactRate(0, 30), 0); assert.equal(contactRate(null, 100), 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')
assert.equal(refName('google.com'), 'Google'); assert.equal(refName('google.co.uk'), 'Google')
assert.equal(refName('www.bing.com'), 'Bing'); assert.equal(refName('l.instagram.com'), 'Instagram')
assert.equal(refName('m.facebook.com'), 'Facebook'); assert.equal(refName('facebook.com'), 'Facebook')
assert.equal(refName('duckduckgo.com'), 'DuckDuckGo'); assert.equal(refName('www.ecosia.org'), 'Ecosia')
assert.equal(refName('notgoogle.com'), 'notgoogle.com'); assert.equal(refName('google.evil.de'), 'google.evil.de')
assert.equal(refName('Google.DE'), 'Google')
})
test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => {
// 05.09.2026 ist ein Samstag: 5 Samstage mit 20, alle anderen 10 → Schnitt 11,67; Samstag 20 ≥ 1,2 × 11,67, aber < 1,8 × 11,67 (kein Spitzentag)
const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10)
const w = weekdayStats(days('2026-09-05', 30, sat), '')
assert.equal(w.best.weekday, 6); assert.equal(w.best.name, 'Samstag'); assert.equal(w.best.avg, 20)
assert.ok(Math.abs(w.avg - 350 / 30) < 1e-9)
assert.equal(w.peak, null)
// ein Tag mit dem 3-Fachen des Schnitts → Spitzentag
const spike = (i, day) => (day === '2026-09-19' ? 60 : 10)
const p = weekdayStats(days('2026-09-05', 30, spike), '')
assert.deepEqual(p.peak, { day: '2026-09-19', pv: 60 })
assert.equal(p.best.name, 'Samstag')
// gleichmäßig: weder stärkster Tag noch Spitzentag
assert.deepEqual(weekdayStats(days('2026-09-05', 30, 10), ''), { best: null, avg: 10, peak: null })
// weniger als 7 Tage → null; firstDay schneidet Tage davor ab
assert.equal(weekdayStats(days('2026-09-05', 6, 10), ''), null)
assert.notEqual(weekdayStats(days('2026-09-05', 30, 10), '2026-09-28'), null) // 28.09.–04.10. = genau 7 Tage
})
test('weekdayStats: firstDay zählt nur Tage ab firstDay', () => {
assert.equal(weekdayStats(days('2026-09-05', 30, 10), '2026-09-29'), null) // nur 6 Tage (29.09.–04.10.)
const w = weekdayStats(days('2026-09-05', 30, (i, day) => (day < '2026-09-20' ? 1000 : 10)), '2026-09-20')
assert.equal(w.avg, 10) // die Tage davor (1000) bleiben außen vor
assert.equal(weekdayStats(null, ''), null)
})
test('summarize: Wachstum mit Prozent', () => {
const s = summarize(stats())
assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – <b>14 % mehr</b> als in den 30 Tagen davor.')
assert.equal(s.items[0].icon, 'contact')
assert.equal(s.items[0].html, '178-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 35 mehr als zuvor.') // Telefon + E-Mail + WhatsApp: 96 + 52 + 30 gegen 80 + 40 + 23
assert.ok(s.items.length <= 4)
})
test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => {
const lead = (totalsPv, prevPv) => summarize(stats({ totals: { ...t, pv: totalsPv }, previous: { ...prev, totals: { ...prev.totals, pv: prevPv } } })).lead
assert.equal(lead(6930, 7615), 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – <b>9 % weniger</b> als in den 30 Tagen davor.')
assert.equal(lead(6930, 6800), 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – etwa so oft wie in den 30 Tagen davor.')
assert.equal(lead(80, 68), 'Deine Website wurde in den letzten 30 Tagen <b>80-mal</b> aufgerufen – <b>12 Aufrufe mehr</b> als in den 30 Tagen davor.')
assert.equal(lead(60, 75), 'Deine Website wurde in den letzten 30 Tagen <b>60-mal</b> aufgerufen – <b>15 Aufrufe weniger</b> als in den 30 Tagen davor.')
assert.equal(lead(84, 83), 'Deine Website wurde in den letzten 30 Tagen <b>84-mal</b> aufgerufen – etwa so oft wie in den 30 Tagen davor.') // unter 5 % gilt auch bei kleinen Zahlen
assert.match(lead(32, 31), /aufgerufen – etwa so oft wie in den 30 Tagen davor\.$/)
assert.equal(lead(40, 30), 'Deine Website wurde in den letzten 30 Tagen <b>40-mal</b> aufgerufen – <b>10 Aufrufe mehr</b> als in den 30 Tagen davor.')
assert.equal(lead(31, 20), 'Deine Website wurde in den letzten 30 Tagen <b>31-mal</b> aufgerufen – <b>11 Aufrufe mehr</b> als in den 30 Tagen davor.')
assert.equal(lead(80, 80), 'Deine Website wurde in den letzten 30 Tagen <b>80-mal</b> aufgerufen – etwa so oft wie in den 30 Tagen davor.')
assert.equal(lead(7200, 6857), 'Deine Website wurde in den letzten 30 Tagen <b>7.200-mal</b> aufgerufen – <b>5 % mehr</b> als in den 30 Tagen davor.') // genau 5 % gilt noch als Veränderung
})
test('summarize: ohne Vergleich und neu gestartet', () => {
// previous fehlt: '…in den letzten 30 Tagen <b>X-mal</b> aufgerufen.'
const { previous, ...ohne } = stats()
assert.equal(summarize(ohne).lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen.')
// Vorzeitraum unvollständig, firstDay vor dem Zeitraum: kein Vergleich, aber kein „Seit dem Start“
assert.equal(summarize(stats({ firstDay: '2026-08-20', previous: { ...prev, complete: false } })).lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen.')
// Vorzeitraum zu klein: kein Vergleich
assert.equal(summarize(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 12 } } })).lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen.')
// firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>X-mal</b> aufgerufen.'
const neu = summarize(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } }))
assert.equal(neu.lead, 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>6.930-mal</b> aufgerufen.')
// auch das Startdatum wird maskiert, falls der Collector je Unsinn liefert
assert.ok(!summarize(stats({ firstDay: '<i>x</i>', previous: { ...prev, complete: false } })).lead.includes('<i>'))
// ohne Vergleich kein „Das sind … mehr als zuvor“
assert.equal(neu.items[0].html, '178-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).')
// 7 Tage: „7 Tagen“
assert.match(summarize(stats({ range: 7 })).lead, /in den letzten 7 Tagen .* als in den 7 Tagen davor\.$/)
})
test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => {
assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null)
assert.equal(summarize(null), null)
assert.notEqual(summarize(stats({ totals: { ...t, pv: 30 } })), null)
// contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.'
const keine = summarize(stats({ totals: { ...t, contacts: 0, visitors: 40 }, contacts: [] }))
assert.deepEqual(keine.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' })
const wenig = summarize(stats({ totals: { ...t, contacts: 0, visitors: 29 }, contacts: [] }))
assert.notEqual(wenig.items[0].icon, 'contact')
})
test('summarize: Kontakte – Vergleichssatz und häufigste Art, Links nach außen zählen nicht mit', () => {
const tel = (n) => ({ key: 'tel', count: n })
const html = (kinds, before) => summarize(stats({
totals: { ...t, contacts: kinds.reduce((a, k) => a + k.count, 0) },
contacts: kinds,
previous: { ...prev, contacts: before, totals: { ...prev.totals, contacts: before.reduce((a, k) => a + k.count, 0) } },
})).items[0].html
assert.equal(html([tel(96)], [tel(108)]), '96-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 12 weniger als zuvor.')
assert.equal(html([{ key: 'mail', count: 100 }], [{ key: 'mail', count: 100 }]), '100-mal hat dich jemand direkt kontaktiert, meist per E-Mail (100). Genauso oft wie zuvor.')
assert.equal(html([{ key: 'wa', count: 5 }], []), '5-mal hat dich jemand direkt kontaktiert, meist per WhatsApp (5).') // Vorwert 0: kein Vergleichssatz
assert.equal(html([tel(5)], [tel(2)]), '5-mal hat dich jemand direkt kontaktiert, meist per Telefon (5). Das sind 3 mehr als zuvor.')
// Links nach außen sind Klicks auf andere Seiten, kein Kontakt: weder in der Zahl noch als häufigste Art, auch nicht im Vorzeitraum
assert.equal(html([{ key: 'out', count: 300 }, tel(40)], [{ key: 'out', count: 900 }, tel(40)]), '40-mal hat dich jemand direkt kontaktiert, meist per Telefon (40). Genauso oft wie zuvor.')
assert.equal(html([{ key: 'out', count: 300 }, tel(40)], [{ key: 'out', count: 900 }]), '40-mal hat dich jemand direkt kontaktiert, meist per Telefon (40).')
// nur Links nach außen (Besucher ≥ 30): wie „keine Kontakte“
const nurOut = summarize(stats({ totals: { ...t, contacts: 300, visitors: 400 }, contacts: [{ key: 'out', count: 300 }] }))
assert.deepEqual(nurOut.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' })
// Collector ohne Kontaktliste: kein Satz, der etwas Falsches behauptet
const { contacts, ...ohneListe } = stats({ totals: { ...t, contacts: 0, visitors: 400 } })
assert.notEqual(summarize(ohneListe).items[0].icon, 'contact')
})
test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => {
// refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).'
const s = summarize(stats())
const byIcon = Object.fromEntries(s.items.map((i) => [i.icon, i.html]))
assert.equal(byIcon.source, 'Die meisten, die über einen Link kamen, kommen von Google (880).')
// devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.'
assert.equal(byIcon.device, '66 % der Aufrufe kommen vom Handy.')
// pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).'
assert.equal(byIcon.page, 'Am meisten angesehen: die Startseite, danach /preise (1.310).')
assert.deepEqual(s.items.map((i) => i.icon), ['contact', 'source', 'device', 'page'])
// Computer, Seite ohne Startseite
const pc = summarize(stats({ devices: [{ key: 'd', count: 600 }, { key: 'm', count: 300 }, { key: 't', count: 100 }], pages: [{ key: '/preise', count: 1310 }, { key: '/', count: 900 }] }))
assert.equal(pc.items.find((i) => i.icon === 'device').html, '60 % der Aufrufe kommen vom Computer.')
assert.equal(pc.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /preise (1.310 Aufrufe).')
// nur Startseite
assert.equal(summarize(stats({ pages: [{ key: '/', count: 50 }] })).items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: die Startseite (50 Aufrufe).')
// Gerät unter 55 %, Herkunft unter 20 %: beide Hinweise entfallen
const flach = summarize(stats({ devices: [{ key: 'm', count: 50 }, { key: 'd', count: 50 }], refs: [{ key: 'google.com', count: 10 }, { key: '', count: 90 }] }))
assert.ok(!flach.items.some((i) => i.icon === 'device' || i.icon === 'source'))
// Direkt (leerer Host) zählt nie als Quelle, auch wenn er vorne steht
const direkt = summarize(stats({ refs: [{ key: '', count: 900 }, { key: 'google.com', count: 300 }] }))
assert.equal(direkt.items.find((i) => i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von Google (300).')
// Samstage 2× → Wochentag-Hinweis; alle 5 Bedingungen wahr → Seite fällt weg
const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10)
const voll = summarize(stats({ series: days('2026-09-05', 30, sat) }))
assert.deepEqual(voll.items.map((i) => i.icon), ['contact', 'day', 'source', 'device'])
assert.equal(voll.items[1].html, 'Samstag ist im Schnitt dein stärkster Tag.')
})
test('summarize: Wochentag und Spitzentag in einem Hinweis', () => {
// Samstage 20, Spitzentag Sa., 19.09. mit 371, sonst 10: beides trifft zu
const both = (i, day) => (day === '2026-09-19' ? 371 : new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 40 : 10)
const s = summarize(stats({ series: days('2026-09-05', 30, both) }))
assert.equal(s.items.find((i) => i.icon === 'day').html, 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 19.09. mit 371 Aufrufen.')
// nur Spitzentag am Mittwoch 23.09.: die anderen Mittwoche (0) gleichen den Wochentagsschnitt aus (alle Wochentage Ø 25)
const spike = (i, day) => (day === '2026-09-23' ? 100 : new Date(day + 'T00:00:00Z').getUTCDay() === 3 ? 0 : 25)
const p = summarize(stats({ series: days('2026-09-05', 30, spike) }))
assert.equal(p.items.find((i) => i.icon === 'day').html, 'Spitzentag war Mi., 23.09. mit 100 Aufrufen.')
// weniger als 70 Aufrufe oder weniger als 7 Tage: kein Wochentag-Hinweis
assert.ok(!summarize(stats({ totals: { ...t, pv: 69 }, series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day'))
assert.ok(!summarize(stats({ firstDay: '2026-09-30', series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day'))
})
test('summarize: Wochentag-Hinweis zählt alle Tage der Reihe (alle sind abgeschlossen), auch in der 7-Tage-Ansicht', () => {
const isSat = (day) => new Date(day + 'T00:00:00Z').getUTCDay() === 6
const dayHtml = (s) => s.items.find((i) => i.icon === 'day')?.html
// Reihe 04.09.–03.10.2026 (Ende = gestern, heute steht getrennt in `today`); der letzte Tag, Samstag 03.10., zählt mit
const last = '2026-10-03'
const base = { from: '2026-09-04', to: last, today: { day: '2026-10-04', pv: 5, visitors: 3, contacts: 0 } }
assert.equal(dayHtml(summarize(stats({ ...base, series: days('2026-09-04', 30, (i, day) => (isSat(day) ? 280 : 200)) }))), 'Samstag ist im Schnitt dein stärkster Tag.')
// Spitzentag am letzten Tag der Reihe wird erkannt
const spike = days('2026-09-04', 30, (i, day) => (day === last ? 400 : 100))
assert.equal(dayHtml(summarize(stats({ ...base, series: spike }))), 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 03.10. mit 400 Aufrufen.')
// 7-Tage-Ansicht: genau 7 abgeschlossene Tage reichen für den Hinweis (28.09.–04.10., Sa. 03.10.)
const week = days('2026-09-28', 7, (i, day) => (isSat(day) ? 500 : 100))
assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', series: week }))), 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 03.10. mit 500 Aufrufen.')
// weniger als 7 Tage (neue Website): weiter kein Hinweis
assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', firstDay: '2026-09-29', series: week }))), undefined)
// alter Collector ohne `today`: derselbe Hinweis, kein Absturz
const { today, ...alt } = stats({ ...base, series: days('2026-09-04', 30, (i, day) => (isSat(day) ? 280 : 200)) })
assert.equal(dayHtml(summarize(alt)), 'Samstag ist im Schnitt dein stärkster Tag.')
})
test('summarize: Pfade und Hosts werden maskiert', () => {
const s = summarize(stats({ pages: [{ key: '/<img src=x>', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] }))
assert.ok(!JSON.stringify(s).includes('<img'))
assert.ok(JSON.stringify(s).includes('&lt;img'))
assert.equal(s.items.find((i) => i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von a&quot;b.de (900).')
assert.equal(s.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /&lt;img src=x&gt; (900 Aufrufe).')
// auch an zweiter Stelle hinter der Startseite
const z = summarize(stats({ pages: [{ key: '/', count: 900 }, { key: '/<script>', count: 100 }] }))
assert.ok(!JSON.stringify(z).includes('<script>'))
})
test('chartModel mit compare: gemeinsame Skala, prevLine', () => {
const cur = days('2026-09-05', 5, 10)
const before = days('2026-08-06', 5, (i) => (i === 2 ? 400 : 20))
const m = chartModel(cur, { compare: before })
assert.equal(m.top, 500) // über beide Reihen (Maximum 400)
assert.equal(m.points.length, 5)
assert.deepEqual(m.points.map((p) => p.prev), [20, 20, 400, 20, 20])
assert.match(m.prevLine, /^M[\d. ]+( L[\d. ]+){4}$/)
// nach Tagesindex ausgerichtet: gleiche x-Werte wie die aktuelle Linie, y am Vorwert
const first = m.prevLine.split(' L')[0].slice(1).split(' ').map(Number)
assert.equal(first[0], m.points[0].x)
const mid = m.prevLine.split(' L')[2].split(' ').map(Number)
assert.equal(mid[0], m.points[2].x)
assert.ok(mid[1] < first[1]) // 400 liegt höher als 20
// ohne compare: wie bisher, prevLine leer
const o = chartModel(cur)
assert.equal(o.top, 10)
assert.equal(o.prevLine, '')
assert.ok(o.points.every((p) => p.prev === null))
assert.equal(o.line, chartModel(cur, { compare: [] }).line)
assert.equal(chartModel(cur, { compare: [] }).prevLine, '')
// kürzerer Vergleich: nur die vorhandenen Tage, Rest null
const k = chartModel(cur, { compare: before.slice(0, 2) })
assert.deepEqual(k.points.map((p) => p.prev), [20, 20, null, null, null])
assert.match(k.prevLine, /^M[\d. ]+ L[\d. ]+$/)
})
+2 -1
View File
@@ -112,7 +112,7 @@ test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', a
const body = await get('/api/funktionen', { cookie }) const body = await get('/api/funktionen', { cookie })
const p = (id) => body.projects.find((x) => x.projectId === id) const p = (id) => body.projects.find((x) => x.projectId === id)
assert.equal(p('p1').features.news.state, 'off') assert.equal(p('p1').features.news.state, 'off')
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } }) assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] }, activatedAt: null })
assert.equal(p('p2').analyse.eligible, false) assert.equal(p('p2').analyse.eligible, false)
assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich') assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich')
assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich) assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich)
@@ -133,6 +133,7 @@ test('Aktivieren und Deaktivieren (Kunde)', async () => {
const view = (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === 'p1').analyse const view = (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === 'p1').analyse
assert.equal(view.state, 'active') assert.equal(view.state, 'active')
assert.equal(view.accentColor, '#112233') assert.equal(view.accentColor, '#112233')
assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/)
const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }) const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie })
assert.deepEqual(off.body, { ok: true, state: 'off' }) assert.deepEqual(off.body, { ok: true, state: 'off' })
assert.equal(stops.length, 1) assert.equal(stops.length, 1)
+11 -1
View File
@@ -37,6 +37,13 @@ test('Diagramm-Skalierung', () => {
assert.ok(m.xLabels.length <= 7 && m.xLabels.length >= 2) assert.ok(m.xLabels.length <= 7 && m.xLabels.length >= 2)
assert.equal(chartModel([]).points.length, 0) assert.equal(chartModel([]).points.length, 0)
assert.equal(chartModel([{ day: '2026-10-01', pv: 3 }]).points.length, 1) assert.equal(chartModel([{ day: '2026-10-01', pv: 3 }]).points.length, 1)
// labelGap: die vorletzte Beschriftung rückt weg, wenn sie der letzten zu nahe kommt (30 Tage: 29.09. ist 79 Einheiten vor 03.10.)
const labels = (opts) => chartModel(series, { width: 640, ...opts }).xLabels.map((l) => l.label)
assert.equal(labels({}).length, 7)
const wide = chartModel(series, { labelGap: 90 }).xLabels
assert.equal(wide.length, 6)
assert.ok(wide.every((l, i, a) => !i || l.x - a[i - 1].x >= 90))
assert.equal(wide[wide.length - 1].label, '30.09.') // letzter Tag bleibt
}) })
test('Balkenzeilen', () => { test('Balkenzeilen', () => {
@@ -54,8 +61,11 @@ test('Portal: Analyse-Ansicht ist verdrahtet', () => {
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/) assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/)
assert.match(html, /id="view-analyse"/) assert.match(html, /id="view-analyse"/)
assert.match(html, /id="analyse-body"/) assert.match(html, /id="analyse-body"/)
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') const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/) assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/)
}) })
test('Portal-Vorschau: Banner-Text ist der des Clients', () => { test('Portal-Vorschau: Banner-Text ist der des Clients', () => {
+34
View File
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict'
import { createAnalyticsClient } from '../server/services/analyticsClient.js' import { createAnalyticsClient } from '../server/services/analyticsClient.js'
import { createAdminServer, createPublicServer } from '../analytics/src/server.js' import { createAdminServer, createPublicServer } from '../analytics/src/server.js'
import { openDb } from '../analytics/src/db.js' import { openDb } from '../analytics/src/db.js'
import { computeStats } from '../analytics/src/stats.js'
import { compareFrom, shortDay } from '../public/analyse-view.js'
const SITE = 'abcdef0123456789abcd' const SITE = 'abcdef0123456789abcd'
const DOMAIN = 'kunde.example.com' const DOMAIN = 'kunde.example.com'
@@ -39,11 +41,24 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as
await post({ t: 'ct', path: '/', kind: 'tel' }) await post({ t: 'ct', path: '/', kind: 'tel' })
await post({ t: 'bn', a: 'accept' }) await post({ t: 'bn', a: 'accept' })
// Frisch gemessen: heute steht nur in `today`, der Zeitraum sind die abgeschlossenen Tage bis gestern
const jetzt = await client.getStats(SITE, '7')
assert.deepEqual(jetzt.today, { day: jetzt.today.day, pv: 3, visitors: 1, contacts: 1 })
assert.equal(jetzt.totals.pv, 0)
assert.ok(jetzt.today.day > jetzt.to && jetzt.series.every((d) => d.day <= jetzt.to))
// Um 36 Stunden zurückdatieren: liegt immer auf einem abgeschlossenen Tag des 7-Tage-Zeitraums
db.prepare('UPDATE events_raw SET ts = ts - 36 * 3600 * 1000 WHERE site = ?').run(SITE)
const stats = await client.getStats(SITE, '7') const stats = await client.getStats(SITE, '7')
assert.equal(stats.totals.pv, 3) assert.equal(stats.totals.pv, 3)
assert.equal(stats.totals.contacts, 1) assert.equal(stats.totals.contacts, 1)
assert.equal(stats.today.pv, 0)
assert.equal(stats.series.length, 7) assert.equal(stats.series.length, 7)
assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3) assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3)
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))
assert.deepEqual(stats.pages, [{ key: '/', count: 2 }, { key: '/kontakt', count: 1 }]) assert.deepEqual(stats.pages, [{ key: '/', count: 2 }, { key: '/kontakt', count: 1 }])
assert.deepEqual(stats.refs, [{ key: '', count: 2 }, { key: 'google.com', count: 1 }]) assert.deepEqual(stats.refs, [{ key: '', count: 2 }, { key: 'google.com', count: 1 }])
assert.equal(stats.banner.accept, 1) assert.equal(stats.banner.accept, 1)
@@ -72,3 +87,22 @@ test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledi
assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince) assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince)
assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true }) assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true })
}) })
test('Vertrag: "Vergleich ab" des Portals ist der erste Tag, an dem der Collector den Vorzeitraum als vollständig meldet', () => {
const firstDay = '2026-09-20'
const evDb = openDb(':memory:')
evDb.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(SITE, 'p1', '["a.de"]')
evDb.prepare("INSERT INTO events_raw (site, ts, type, path, vp, country, device) VALUES (?,?,'pv','/','d','DE','d')").run(SITE, Date.UTC(2026, 8, 20, 10))
for (const range of [7, 30]) {
const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10)
const at = (day) => computeStats(evDb, SITE, range, Date.parse(`${day}T10:00:00Z`))
const first = addDays(firstDay, 2 * range) // "Vergleich ab" laut Portal
const dayBefore = at(addDays(first, -1))
assert.equal(dayBefore.previous.complete, false, `range ${range}: am Tag davor noch nicht vollständig`)
assert.deepEqual(compareFrom(dayBefore), { ok: false, from: shortDay(first) })
const onDay = at(first)
assert.equal(onDay.previous.complete, true, `range ${range}: am Tag selbst vollständig`)
assert.equal(onDay.previous.from, firstDay)
assert.equal(compareFrom(onDay).from, '') // kein Datum mehr (der Vorzeitraum ist voll; ok bleibt false, denn es gibt nur 1 Aufruf)
}
})
+1 -1
View File
@@ -25,7 +25,7 @@ test('Entwurfsseite ohne Fassung ist nicht mehr als Datei vorhanden', () => {
}) })
test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => { test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => {
const fn = readFileSync(root('public/funktionen.js'), 'utf8') const fn = readFileSync(root('public/analyse.js'), 'utf8')
for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) { for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) {
assert.ok(!fn.includes(old), old) assert.ok(!fn.includes(old), old)
} }
+1 -1
View File
@@ -109,8 +109,8 @@ test('bestellseite_hinweis: GET /api/funktionen liefert ustHinweis', async () =>
const dir = mkdtempSync(join(tmpdir(), 'fn-recht-')) const dir = mkdtempSync(join(tmpdir(), 'fn-recht-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const clientQuelle = lesen('public/funktionen.js') const clientQuelle = lesen('public/funktionen.js')
.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`) .replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
const clientDatei = join(dir, 'funktionen.mjs') const clientDatei = join(dir, 'funktionen.mjs')
writeFileSync(clientDatei, clientQuelle) writeFileSync(clientDatei, clientQuelle)
+5 -3
View File
@@ -5,16 +5,18 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js und der relative ./vertraege-link.js werden auf die Dateien umgeleitet) // public/funktionen.js wirklich laden (die absoluten Importe /fn-util.js und /analyse-view.js und der relative ./vertraege-link.js werden
// auf die Dateien umgeleitet); analyse.js importiert relativ und wird direkt geladen
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-')) const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`) .replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
const file = join(dir, 'funktionen.mjs') const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src) writeFileSync(file, src)
const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href) const { initFunktionen, initNews } = await import(pathToFileURL(file).href)
const { initAnalyse, renderAnalyseOff } = await import(fileUrl('analyse.js'))
const news = { state: 'off' } const news = { state: 'off' }
const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse }) const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse })
+5 -1
View File
@@ -65,7 +65,11 @@ test('importNewRepos legt nur fehlende an', async () => {
const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu') const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu')
assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', '']) assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', ''])
assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat')) assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat'))
assert.ok(!aw.all('websiteProjects').some((d) => d.repoFullName === 'JUSN/archiv')) // In Gitea archivierte Repos kommen als archiviertes Projekt an (sichtbar unter "Archiviert"),
// aber nicht in createdRepos (keine Zuordnungs-Mail)
const archiv = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/archiv')
assert.deepEqual([archiv.archived, archiv.status], [true, 'archived'])
assert.equal(r.archivedImported, 1)
assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0) assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0)
}) })
test('importNewRepos läuft nie doppelt', async () => { test('importNewRepos läuft nie doppelt', async () => {
+8
View File
@@ -223,6 +223,14 @@ test('Webhook des System-Kontos', async () => {
await webhook(pushPayload({ full_name: 'WEBklar/D-ner-Nation', sha: 'cafe0001', pusher: 'webklar-system' })) await webhook(pushPayload({ full_name: 'WEBklar/D-ner-Nation', sha: 'cafe0001', pusher: 'webklar-system' }))
assert.equal(aw.all('worksheets').length, before) assert.equal(aw.all('worksheets').length, before)
}) })
test('System-Commit an Projekt mit Kunde ohne Ticket legt kein Auto-Ticket an', async () => {
gitea.addRepo({ full_name: 'WEBklar/neu-mit-kunde' })
aw.insert('websiteProjects', { $id: 'p_sys', repoFullName: 'WEBklar/neu-mit-kunde', projectName: 'Neu', customerId: 'c1', ticketId: '' })
const before = aw.all('workorders').length
await webhook(pushPayload({ full_name: 'WEBklar/neu-mit-kunde', sha: 'cafe0002', pusher: 'webklar-system' }))
assert.equal(aw.all('workorders').length, before)
assert.equal(aw.get('websiteProjects', 'p_sys').ticketId, '')
})
test('Webhook wartet auf laufenden Umzug', async () => { test('Webhook wartet auf laufenden Umzug', async () => {
gitea.setTransferDelay(300) gitea.setTransferDelay(300)
const move = post('/api/admin/website-projects/p5/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') // p5: JUSN/langsam const move = post('/api/admin/website-projects/p5/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') // p5: JUSN/langsam
+41 -9
View File
@@ -28,6 +28,10 @@ import { randomUUID } from 'node:crypto'
export async function startFakeAppwrite(options = {}) { export async function startFakeAppwrite(options = {}) {
const calls = [] const calls = []
const pendingFailures = [] const pendingFailures = []
// Optionale Unique-Indizes wie in Appwrite/MariaDB: { websiteProjects: ['repoFullName', 'subdomain'] }.
// Strings vergleichen ohne Gross-/Kleinschreibung, null/undefined kollidieren nie, '' schon.
const uniqueAttrs = options.unique || {}
let beforeCreate = null
/** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */ /** @type {Map<string, Map<string, object>>} Sammlungsname -> (Dokument-ID -> Dokument). */
const store = new Map() const store = new Map()
@@ -114,11 +118,24 @@ export async function startFakeAppwrite(options = {}) {
send(res, 200, { total, documents: result }) send(res, 200, { total, documents: result })
} }
function handleCreateDocument(match, searchParams, body, headers, res) { function uniqueConflict(col, data, ownId) {
for (const attr of uniqueAttrs[col] || []) {
const value = data?.[attr]
if (value === null || value === undefined) continue
const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : v)
for (const doc of collectionOf(col).values()) {
if (doc.$id !== ownId && doc[attr] !== null && doc[attr] !== undefined && norm(doc[attr]) === norm(value)) return true
}
}
return false
}
async function handleCreateDocument(match, searchParams, body, headers, res) {
if (beforeCreate) await beforeCreate(match[2], body?.data || {})
const map = collectionOf(match[2]) const map = collectionOf(match[2])
const documentId = body?.documentId || randomUUID() const documentId = body?.documentId || randomUUID()
if (map.has(documentId)) { if (map.has(documentId) || uniqueConflict(match[2], body?.data, documentId)) {
return send(res, 409, { message: 'Document with the requested ID already exists.' }) return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' })
} }
const now = new Date().toISOString() const now = new Date().toISOString()
const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) } const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) }
@@ -135,6 +152,9 @@ export async function startFakeAppwrite(options = {}) {
function handlePatchDocument(match, searchParams, body, headers, res) { function handlePatchDocument(match, searchParams, body, headers, res) {
const doc = collectionOf(match[2]).get(match[3]) const doc = collectionOf(match[2]).get(match[3])
if (!doc) return send(res, 404, { message: 'Document not found' }) if (!doc) return send(res, 404, { message: 'Document not found' })
if (uniqueConflict(match[2], body?.data, doc.$id)) {
return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' })
}
Object.assign(doc, body?.data || {}) Object.assign(doc, body?.data || {})
doc.$updatedAt = new Date().toISOString() doc.$updatedAt = new Date().toISOString()
send(res, 200, doc) send(res, 200, doc)
@@ -212,11 +232,9 @@ export async function startFakeAppwrite(options = {}) {
const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname)) const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname))
if (!route) return send(res, 404, { message: 'Not Found' }) if (!route) return send(res, 404, { message: 'Not Found' })
const match = route.re.exec(url.pathname) const match = route.re.exec(url.pathname)
try { Promise.resolve()
route.handler(match, url.searchParams, parsedBody, req.headers, res) .then(() => route.handler(match, url.searchParams, parsedBody, req.headers, res))
} catch (err) { .catch((err) => send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' }))
send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' })
}
}) })
}) })
@@ -245,10 +263,24 @@ export async function startFakeAppwrite(options = {}) {
pendingFailures.push({ method, pathIncludes, status }) pendingFailures.push({ method, pathIncludes, status })
} }
/** fn(collection, data) laeuft vor jeder Anlage (z. B. um einen gleichzeitigen Schreiber nachzustellen). */
function setBeforeCreate(fn) {
beforeCreate = fn || null
}
/** Dokument direkt in den Speicher legen (ohne HTTP), z. B. aus einem setBeforeCreate-Haken. */
function insert(col, doc) {
const now = new Date().toISOString()
const id = doc.$id || randomUUID()
const full = { $createdAt: now, $updatedAt: now, ...doc, $id: id }
collectionOf(col).set(id, full)
return full
}
function close() { function close() {
for (const socket of sockets) socket.destroy() for (const socket of sockets) socket.destroy()
return new Promise((resolve) => server.close(() => resolve())) return new Promise((resolve) => server.close(() => resolve()))
} }
return { endpoint, all, get, calls, failNext, close } return { endpoint, all, get, calls, failNext, setBeforeCreate, insert, close }
} }
+32
View File
@@ -63,6 +63,7 @@ export async function startFakeGitea(options = {}) {
let transferDelayMs = 0 let transferDelayMs = 0
let responseDelayMs = 0 let responseDelayMs = 0
let transferNoopNext = false let transferNoopNext = false
let onGenerate = null
const pendingFailures = [] const pendingFailures = []
let nextRepoId = 1 let nextRepoId = 1
@@ -229,6 +230,30 @@ export async function startFakeGitea(options = {}) {
send(res, 200, serializeRepo(found.record)) send(res, 200, serializeRepo(found.record))
} }
/**
* Repo aus Vorlage erzeugen wie Gitea (`POST /repos/{tplOwner}/{tpl}/generate`): 409, wenn es das
* Ziel schon gibt. `setOnGenerate(fn)` ruft fn(repo) nach dem Anlegen auf, BEVOR geantwortet
* wird - so laesst sich der sofort feuernde Webhook `repository/created` nachstellen.
*/
async function handleGenerate(match, searchParams, body, headers, res) {
const owner = body?.owner || ''
const name = body?.name || ''
if (!owner || !name) return send(res, 422, { message: 'owner und name fehlen' })
if (repos.has(keyFrom(owner, name))) {
return send(res, 409, { message: 'The repository with the same name already exists.' })
}
const record = addRepoInternal({ full_name: `${owner}/${name}`, description: body.description || '', private: body.private })
if (onGenerate) await onGenerate(serializeRepo(record))
send(res, 201, serializeRepo(record))
}
function handleGetBranches(match, searchParams, body, headers, res) {
const found = findRepoOrRedirect(match[1], match[2])
if (!found) return send(res, 404, { message: 'Not Found' })
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/branches')
send(res, 200, [{ name: found.record.default_branch || 'main' }])
}
function handlePatchRepo(match, searchParams, body, headers, res) { function handlePatchRepo(match, searchParams, body, headers, res) {
const found = findRepoOrRedirect(match[1], match[2]) const found = findRepoOrRedirect(match[1], match[2])
if (!found) return send(res, 404, { message: 'Not Found' }) if (!found) return send(res, 404, { message: 'Not Found' })
@@ -419,6 +444,8 @@ export async function startFakeGitea(options = {}) {
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks },
{ method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook }, { method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook },
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds },
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/generate$/, handler: handleGenerate },
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/branches$/, handler: handleGetBranches },
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents },
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents }, { method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
{ method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents }, { method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
@@ -542,6 +569,10 @@ export async function startFakeGitea(options = {}) {
pendingFailures.push({ method, pathIncludes, status }) pendingFailures.push({ method, pathIncludes, status })
} }
function setOnGenerate(fn) {
onGenerate = fn || null
}
function close() { function close() {
for (const socket of sockets) socket.destroy() for (const socket of sockets) socket.destroy()
return new Promise((resolve) => server.close(() => resolve())) return new Promise((resolve) => server.close(() => resolve()))
@@ -549,6 +580,7 @@ export async function startFakeGitea(options = {}) {
return { return {
baseUrl, baseUrl,
setOnGenerate,
calls, calls,
repo, repo,
addRepo, addRepo,
+11
View File
@@ -67,3 +67,14 @@ test('Route: anderer Kunde bleibt ausgesperrt', async () => {
assert.equal(r.allowed, false) assert.equal(r.allowed, false)
assert.equal(r.reason, 'no_project') assert.equal(r.reason, 'no_project')
}) })
test('Route: archiviertes Projekt ist fuer den Kunden gesperrt (Entscheidung 2026-10-04)', async () => {
const doc = aw.all('websiteProjects').find((d) => d.$id === 'p1')
doc.archived = true
try {
const r = await access(token.c1)
assert.equal(r.allowed, false)
assert.equal(r.reason, 'no_project')
} finally {
doc.archived = false
}
})
+26
View File
@@ -103,6 +103,32 @@ test('Schalter aus: kein Runner-Aufruf, alter Pfad', async () => {
assert.equal(rs.calls.length, 0) assert.equal(rs.calls.length, 0)
}) })
test('fremde Subdomain (kopierte Config): kein Runner-Auftrag, Projekt unverändert', async () => {
const original = await project({ subdomain: 'original-seite' })
const kopie = await project()
const r = await pd.runDeploy(kopie.repo, 'main', { ...cfg(kopie.repo), subdomain: 'original-seite' }, { project: kopie.doc, sha: SHA })
assert.equal(r.blocked, true)
assert.equal(r.owner, original.repo)
assert.equal(submits().length, 0)
assert.equal(aw.get('websiteProjects', kopie.id).previewState, undefined)
// danach laeuft ein normaler Auftrag fuer dasselbe Repo wieder an
const ok = await pd.runDeploy(kopie.repo, 'main', cfg(kopie.repo), { project: kopie.doc, sha: SHA })
assert.equal(ok.queued, true)
await pd.waitForRunFollowers()
})
test('gesperrter Aufruf waehrend eines laufenden Baus: der Verfolger bringt den Bau trotzdem online', async () => {
await project({ subdomain: 'andere-seite' })
const { id, repo, doc } = await project()
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
assert.equal(r.queued, true)
const blocked = await pd.runDeploy(repo, 'main', { ...cfg(repo), subdomain: 'andere-seite' }, { project: doc, sha: SHA })
assert.equal(blocked.blocked, true)
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewState, 'online')
assert.equal(submits().length, 1)
})
test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis online', async () => { test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis online', async () => {
const { id, repo, doc } = await project({ projectName: 'Mein Projekt', previewEnv: envLib.encryptEnv({ API_URL: 'https://x.test' }, key) }) const { id, repo, doc } = await project({ projectName: 'Mein Projekt', previewEnv: envLib.encryptEnv({ API_URL: 'https://x.test' }, key) })
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
+253
View File
@@ -0,0 +1,253 @@
// Ablauf Projekt anlegen / archivieren / Import im Ticketsystem (Fehler aus dem Live-Durchlauf 2026-10-04):
// - "Neues Projekt" ohne Subdomain schrieb subdomain '' -> Unique-Index -> "Document with the requested ID already exists"
// - Wettlauf mit dem Webhook repository/created beim Anlegen
// - Reaktivieren liess status 'archived' stehen
// - archivierte Repos kamen nie ins Ticketsystem, Archiv-Stand aus Gitea nie an
// - "Gitea-Repos synchronisieren" ueberschrieb Projektnamen
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { startFakeGitea } from './helpers/fakeGitea.js'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
let gitea
let aw
let config
let svc
let handleRepositoryCreated
let upsertWebsiteProjectByRepo
const docs = () => aw.all('websiteProjects')
const docOf = (name) => docs().find((d) => String(d.repoFullName).toLowerCase() === name.toLowerCase())
before(async () => {
gitea = await startFakeGitea({
repos: [
{ full_name: 'knso/webklar-preview-template' },
{ full_name: 'knso/gradient-component' },
{ full_name: 'WEBklar/ohne-site' },
{ full_name: 'WEBklar/mit-site' },
{ full_name: 'WEBklar/D-ner-Nation', description: 'D-ner-Nation' },
],
})
aw = await startFakeAppwrite({
unique: { websiteProjects: ['repoFullName', 'subdomain'] },
seed: {
websiteProjects: [
// wie live: ein altes Projekt traegt subdomain '' (danach scheiterte jedes weitere ohne Website)
{ repoFullName: 'knso/gradient-component', projectName: 'gradient component', subdomain: '' },
{ $id: 'p_ohne', repoFullName: 'WEBklar/ohne-site', projectName: 'Ohne Site', status: 'created' },
{ $id: 'p_mit', repoFullName: 'WEBklar/mit-site', projectName: 'Mit Site', subdomain: 'mit-site', status: 'deployed' },
{ $id: 'p_doener', repoFullName: 'WEBklar/D-ner-Nation', projectName: 'Döner Nation', subdomain: 'doener-nation' },
],
customers: [{ $id: 'c1', name: 'Kunde' }],
},
})
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'test-token',
GITEA_DEFAULT_OWNER: 'WEBklar',
GITEA_TEMPLATE_OWNER: 'knso',
GITEA_TEMPLATE_REPO: 'webklar-preview-template',
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: '',
TRAEFIK_DYNAMIC_DIR: '',
})
;({ config } = await import('../server/config.js'))
svc = await import('../server/services/giteaAdmin.js')
;({ handleRepositoryCreated } = await import('../server/services/repoAssignJob.js'))
;({ upsertWebsiteProjectByRepo } = await import('../server/services/appwriteAdmin.js'))
})
after(async () => {
await gitea.close()
await aw.close()
})
test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt dran', async () => {
const r = await svc.createProjectFromTemplate({ displayName: 'Zweites ohne Website', customerId: 'c1' })
assert.equal(r.repoFullName, 'WEBklar/zweites-ohne-website')
assert.equal(r.projectType, 'repo')
const d = docOf('WEBklar/zweites-ohne-website')
assert.equal(d.subdomain, null)
assert.equal(d.customerId, 'c1')
assert.equal(d.projectName, 'Zweites ohne Website')
// Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort)
const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate'))
assert.equal(gen.body.description, 'Zweites ohne Website')
// Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04)
assert.equal(gen.body.private, true)
})
test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => {
gitea.setOnGenerate(async (repo) => {
await handleRepositoryCreated({ action: 'created', repository: repo, sender: { login: 'webklar-system' } }, {
sendMail: async () => ({ sent: false }),
})
})
try {
const r = await svc.createProjectFromTemplate({ displayName: 'Webhook zuerst', customerId: 'c1', ticketId: 't1' })
const all = docs().filter((d) => d.repoFullName === r.repoFullName)
assert.equal(all.length, 1)
assert.equal(all[0].customerId, 'c1')
assert.equal(all[0].ticketId, 't1')
assert.equal(all[0].projectType, 'repo')
assert.equal(all[0].createdByGitea, 'webklar-system')
} finally {
gitea.setOnGenerate(null)
}
})
test('Wettlauf zwischen Suche und Anlage: 409 wird zum Update statt zur Fehlermeldung', async () => {
let raced = false
aw.setBeforeCreate((col, data) => {
if (col !== 'websiteProjects' || raced || data.repoFullName !== 'WEBklar/wettlauf') return
raced = true
aw.insert('websiteProjects', { repoFullName: 'WEBklar/wettlauf', projectName: 'wettlauf', status: 'synced' })
})
try {
const d = await upsertWebsiteProjectByRepo('WEBklar/wettlauf', { repoFullName: 'WEBklar/wettlauf', customerId: 'c1', projectName: 'Wettlauf' })
assert.ok(raced)
assert.equal(d.customerId, 'c1')
assert.equal(docs().filter((x) => x.repoFullName === 'WEBklar/wettlauf').length, 1)
} finally {
aw.setBeforeCreate(null)
}
})
test('Neues Projekt mit Art (Ticket): kind gesetzt; unbekannte Art -> 400 ohne Repo', async () => {
await svc.createProjectFromTemplate({ displayName: 'Mit Art', customerId: 'c1', ticketId: 't2', kind: 'website', staffName: 'Kenso' })
const d = docOf('WEBklar/mit-art')
assert.deepEqual([d.kind, d.forCustomer, d.kindSetBy, d.previewMode], ['website', true, 'Kenso', 'auto'])
assert.ok(d.kindSetAt)
const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length
await assert.rejects(svc.createProjectFromTemplate({ displayName: 'Falsch', kind: 'quatsch' }), (err) => err.status === 400)
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before)
})
test('Typ "Internes Projekt" schreibt null statt leerer Subdomain', async () => {
const r = await svc.updateWebsiteProject('p_mit', { projectType: 'project' })
assert.equal(r.subdomain, '')
assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, null)
// fuer die folgenden Tests wieder als Website
await svc.updateWebsiteProject('p_mit', { projectType: 'preview', subdomain: 'mit-site' })
assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, 'mit-site')
})
test('Archivieren und Reaktivieren ohne Website: status geht zurueck auf synced', async () => {
await svc.archiveRepo('p_ohne', true)
assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [true, 'archived'])
assert.equal(gitea.repo('WEBklar/ohne-site').archived, true)
await svc.archiveRepo('p_ohne', false)
assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [false, 'synced'])
assert.equal(gitea.repo('WEBklar/ohne-site').archived, false)
})
test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Deploy meldet)', async () => {
await svc.archiveRepo('p_mit', true)
assert.equal(aw.get('websiteProjects', 'p_mit').status, 'archived')
await svc.archiveRepo('p_mit', false)
const d = aw.get('websiteProjects', 'p_mit')
assert.equal(d.archived, false)
// hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy -> 'pending' und als fehlgeschlagen markiert
assert.deepEqual([d.status, d.provisioningStatus], ['pending', 'deploy_failed'])
// der Status wird vor dem Redeploy geschrieben, danach nicht mehr (der Runner setzt 'deployed')
const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit'))
assert.equal(patches.at(-1).body.data.status, undefined)
})
test('Neues Projekt: alter verknuepfter Eintrag mit demselben Namen blockiert mit klarer Meldung, bevor ein Repo entsteht', async () => {
aw.insert('websiteProjects', { repoFullName: 'WEBklar/alt-belegt', projectName: 'Alt', archived: true, status: 'archived', kind: 'website', customerId: 'cAlt' })
const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length
await assert.rejects(svc.createProjectFromTemplate({ displayName: 'alt belegt', repoName: 'alt-belegt' }), (err) =>
err.status === 409 && /alten Projekt/.test(err.message) && /archiviert/.test(err.message))
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before)
})
test('Neues Projekt: alter Platzhalter ohne Bezug wird entfernt, das neue Projekt startet sauber', async () => {
aw.insert('websiteProjects', { $id: 'platzhalter', repoFullName: 'WEBklar/frei-machen', projectName: 'alt', archived: true, status: 'archived' })
const r = await svc.createProjectFromTemplate({ displayName: 'Frei machen', customerId: 'c1' })
assert.equal(aw.get('websiteProjects', 'platzhalter'), undefined)
const d = docOf(r.repoFullName)
assert.equal(d.archived, false)
assert.equal(d.customerId, 'c1')
})
test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => {
await svc.syncReposToAppwrite()
assert.equal(aw.get('websiteProjects', 'p_doener').projectName, 'Döner Nation')
})
test('Import: archivierte Repos kommen als archiviert an (ohne Mail-Liste), Gitea-Archivstand wird gespiegelt, ein Fehler bricht nicht ab', async () => {
const g = await startFakeGitea({
repos: [
{ full_name: 'knso/neu-archiviert', archived: true, description: 'Alt' },
{ full_name: 'knso/kaputt' },
{ full_name: 'knso/neu-aktiv' },
{ full_name: 'knso/in-gitea-archiviert', archived: true },
{ full_name: 'knso/in-gitea-reaktiviert' },
],
})
const a = await startFakeAppwrite({
seed: {
websiteProjects: [
{ $id: 'x1', repoFullName: 'knso/in-gitea-archiviert', projectName: 'A', status: 'synced', archived: false },
{ $id: 'x2', repoFullName: 'knso/in-gitea-reaktiviert', projectName: 'B', status: 'archived', archived: true },
],
},
})
const original = { gitea: config.gitea.baseUrl, appwrite: config.appwrite.endpoint }
config.gitea.baseUrl = g.baseUrl
config.appwrite.endpoint = a.endpoint
const originalError = console.error
console.error = () => {}
try {
// erste Anlage (knso/kaputt kommt nach dem archivierten) schlaegt fehl
a.failNext({ method: 'POST', pathIncludes: '/collections/websiteProjects/documents', status: 400 })
const r = await svc.importNewRepos()
const byName = (n) => a.all('websiteProjects').find((d) => d.repoFullName === n)
// der erzwungene Fehler trifft die erste Anlage: das archivierte neue Repo
assert.deepEqual(r.failed, ['knso/neu-archiviert'])
assert.ok(byName('knso/kaputt'))
assert.ok(byName('knso/neu-aktiv'))
assert.deepEqual(r.createdRepos.map((x) => x.repo.full_name).sort(), ['knso/kaputt', 'knso/neu-aktiv'])
assert.equal(r.archiveSynced, 2)
assert.deepEqual([a.get('websiteProjects', 'x1').archived, a.get('websiteProjects', 'x1').status], [true, 'archived'])
assert.deepEqual([a.get('websiteProjects', 'x2').archived, a.get('websiteProjects', 'x2').status], [false, 'synced'])
// naechster Lauf holt das archivierte Repo nach - als archiviert, ohne Mail-Liste
const r2 = await svc.importNewRepos()
assert.equal(r2.archivedImported, 1)
assert.deepEqual(r2.createdRepos, [])
const arch = byName('knso/neu-archiviert')
assert.deepEqual([arch.archived, arch.status, arch.projectName], [true, 'archived', 'Alt'])
// dritter Lauf: nichts mehr zu tun
const r3 = await svc.importNewRepos()
assert.deepEqual([r3.created, r3.archivedImported, r3.archiveSynced, r3.failed.length], [0, 0, 0, 0])
} finally {
config.gitea.baseUrl = original.gitea
config.appwrite.endpoint = original.appwrite
console.error = originalError
await g.close()
await a.close()
}
})
test('Vorlage fuer "Neues Projekt" zieht beim Zuweisen nie um', async () => {
const { isInternalRepo } = await import('../server/services/repoMove.js')
assert.equal(await isInternalRepo('knso/webklar-preview-template'), true)
})
test('CORS erlaubt PUT (Preview-Variablen speichern)', async () => {
const { corsMiddleware } = await import('../server/middleware/cors.js')
const headers = {}
let status = 0
const req = { method: 'OPTIONS', get: (h) => (h === 'Origin' ? 'https://ticket.webklar.com' : undefined) }
const res = { setHeader: (k, v) => { headers[k] = v }, sendStatus: (s) => { status = s } }
corsMiddleware(['https://ticket.webklar.com'])(req, res, () => {})
assert.equal(status, 204)
assert.match(headers['Access-Control-Allow-Methods'], /\bPUT\b/)
})
+87
View File
@@ -0,0 +1,87 @@
// Entscheidungen vom 04.10.2026: Projekt-Aktionen fuer alle Mitarbeiter, archivierte Projekte im
// Kundenportal ausgeblendet. (Private Repos: siehe projektAblauf.test.js, generate-Aufruf.)
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp, rawGet, get, send, sessionCookie } = await import('./support/testApp.js')
const { default: projectsRouter } = await import('../server/routes/projects.js')
const { default: servicesRouter } = await import('../server/routes/admin/services.js')
const { default: customersRouter } = await import('../server/routes/admin/customers.js')
const { default: websiteProjectsRouter } = await import('../server/routes/admin/websiteProjects.js')
const { default: giteaRouter } = await import('../server/routes/admin/gitea.js')
const { default: funktionenRouter } = await import('../server/routes/funktionen.js')
const app = await startTestApp({
'/api/projects': projectsRouter,
'/api/admin/services': servicesRouter,
'/api/admin/customers': customersRouter,
'/api/admin/website-projects': websiteProjectsRouter,
'/api/admin/gitea': giteaRouter,
'/api/funktionen': funktionenRouter,
})
after(() => Promise.all([aw.close(), st.close(), app.close()]))
aw.seed('websiteProjects', { $id: 'p_aktiv', customerId: 'c1', projectName: 'Aktiv', subdomain: 'aktiv', status: 'deployed' })
aw.seed('websiteProjects', { $id: 'p_archiv', customerId: 'c1', projectName: 'Alt', subdomain: 'alt', status: 'archived', archived: true })
const kunde = { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) }
const bearer = (jwt) => ({ headers: { Authorization: `Bearer ${jwt}` } })
test('Kundenportal: archivierte Projekte stehen nicht in der Liste', async () => {
const { projects } = await get('/api/projects', kunde)
assert.deepEqual(projects.map((p) => p.id), ['p_aktiv'])
})
test('Kundenportal: archiviertes Projekt auch per Direktaufruf nicht erreichbar', async () => {
assert.equal((await rawGet('/api/projects/p_archiv/work', kunde)).status, 404)
assert.equal((await rawGet('/api/projects/p_aktiv/work', kunde)).status, 200)
})
test('Projekt-Routen: Mitarbeiter ohne Admin-Label kommen rein, Kunden-Konto nicht', async () => {
assert.equal((await rawGet('/api/admin/services', bearer('jwt-staff'))).status, 200)
assert.equal((await rawGet('/api/admin/services', bearer('jwt-admin'))).status, 200)
const kundeKonto = await rawGet('/api/admin/services', bearer('jwt-kunde'))
assert.equal(kundeKonto.status, 403)
assert.equal(kundeKonto.body.error, 'Nur für Mitarbeiter')
assert.equal((await rawGet('/api/admin/services')).status, 401)
})
test('Kunden-Verwaltung bleibt nur fuer Admins', async () => {
const r = await rawGet('/api/admin/customers', bearer('jwt-staff'))
assert.equal(r.status, 403)
assert.equal(r.body.error, 'Admin-Berechtigung erforderlich')
})
test('Funktionen: Kunde sieht archivierte Projekte nicht, die Mitarbeiter-Ansicht schon', async () => {
const { listCustomerFeatures } = await import('../server/services/siteFeatures.js')
assert.deepEqual((await listCustomerFeatures('c1', { docs: [] })).map((p) => p.projectId), ['p_aktiv'])
assert.deepEqual(
(await listCustomerFeatures('c1', { docs: [], includeArchived: true })).map((p) => p.projectId).sort(),
['p_aktiv', 'p_archiv']
)
})
test('Funktionen: archiviertes Projekt mit laufender Funktion bleibt fuer den Kunden sichtbar (Buchungen verwalten)', async () => {
const { listCustomerFeatures } = await import('../server/services/siteFeatures.js')
const docs = [{ customerId: 'c1', projectId: 'p_archiv', featureKey: 'termine', state: 'active' }]
assert.deepEqual((await listCustomerFeatures('c1', { docs })).map((p) => p.projectId).sort(), ['p_aktiv', 'p_archiv'])
const aus = [{ customerId: 'c1', projectId: 'p_archiv', featureKey: 'termine', state: 'off' }]
assert.deepEqual((await listCustomerFeatures('c1', { docs: aus })).map((p) => p.projectId), ['p_aktiv'])
})
test('Neu bestellen auf archiviertem Projekt geht nicht', async () => {
const r = await send('POST', '/api/funktionen/news/checkout', { ...kunde, body: { projectId: 'p_archiv', consent: true } })
assert.equal(r.status, 409)
assert.match(r.body.error, /archiviert/)
})
test('Vorlage bleibt Admin-Sache; README nur fuer bekannte Projekte', async () => {
const t = await send('PATCH', '/api/admin/website-projects/p_aktiv', { ...bearer('jwt-staff'), body: { projectType: 'template' } })
assert.equal(t.status, 403)
const fremd = await rawGet('/api/admin/gitea/repo-readme?repoFullName=JUSN/tickte-system', bearer('jwt-staff'))
assert.equal(fremd.status, 404)
})
+11
View File
@@ -126,6 +126,17 @@ test('Job: Mail ohne vorherigen Versuch wird nachgeholt (createdByGitea, 0 Versu
assert.equal(sink.sent.length, 1) assert.equal(sink.sent.length, 1)
}) })
test('Job: vom System-Konto angelegtes Projekt ohne Art wird nicht abgefragt und nicht gemailt', async () => {
freshSink()
gitea.addRepo({ full_name: 'WEBklar/aus-womS', created_at: ago(1) })
aw.insert('websiteProjects', { repoFullName: 'WEBklar/aus-womS', projectName: 'aus WOMS', createdByGitea: 'webklar-system', assignMailAttempts: 0 })
const before = gitea.calls.filter((c) => c.path === '/api/v1/repos/WEBklar/aus-womS').length
const c = await runRepoAssignJob({ now: NOW })
assert.equal(c.retries, 0)
assert.equal(sink.sent.length, 0)
assert.equal(gitea.calls.filter((x) => x.path === '/api/v1/repos/WEBklar/aus-womS').length, before)
})
test('Job: neues Nutzer-Repo → Mail, Org-Repo → keine Mail', async () => { test('Job: neues Nutzer-Repo → Mail, Org-Repo → keine Mail', async () => {
freshSink() freshSink()
gitea.addRepo({ full_name: 'knso/jobneu', created_at: ago(1) }) gitea.addRepo({ full_name: 'knso/jobneu', created_at: ago(1) })
+249
View File
@@ -0,0 +1,249 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHmac } from 'node:crypto'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import express from 'express'
import { startFakeGitea } from './helpers/fakeGitea.js'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
// Sperre gegen fremde Subdomains: Ein kopiertes Repo (git clone + push, Vorlage) bringt die
// .webklar-preview.json des Originals mit. Ohne Sperre ueberschrieb sein erster Push die
// Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026).
const WEBHOOK_TOKEN = 's3cret'
const ORIGINAL = 'WEBklar/Olzinger'
const KOPIE = 'WEBklar/HSK-Kopie'
const previewJson = (subdomain) =>
JSON.stringify({ enabled: true, type: 'static', branch: 'main', displayName: 'Olzinger', subdomain })
let gitea
let aw
let tmp
let originalCwd
let server
let baseUrl
let foreignSubdomainOwner
let runDeploy
let foreignSubdomainBlock
function pushPayload(repo, { sha, branch = 'main', pusher = 'knso' }) {
return {
ref: `refs/heads/${branch}`,
repository: { full_name: repo, name: repo.split('/')[1], html_url: `${gitea.baseUrl}/${repo}` },
commits: [{ id: sha, message: `Commit ${sha}`, author: { name: pusher } }],
pusher: { username: pusher },
after: sha,
}
}
async function webhook(payload) {
const bodyText = JSON.stringify(payload)
const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex')
const res = await fetch(`${baseUrl}/webhook/gitea`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature },
body: bodyText,
})
return { status: res.status, body: await res.json().catch(() => null) }
}
const docOf = (repo) => aw.all('websiteProjects').find((d) => d.repoFullName === repo)
before(async () => {
gitea = await startFakeGitea({
repos: [
{ full_name: ORIGINAL },
{ full_name: KOPIE },
{ full_name: 'WEBklar/neu-name' },
{ full_name: 'WEBklar/relaunch-kopie' },
{ full_name: 'WEBklar/eigene-seite' },
],
redirects: { 'knso/alt-name': 'WEBklar/neu-name' },
contents: {
[`${ORIGINAL}:.webklar-preview.json`]: previewJson('olzinger'),
[`${KOPIE}:.webklar-preview.json`]: previewJson('olzinger'),
'WEBklar/relaunch-kopie:.webklar-preview.json': previewJson('relaunch'),
// hat schon eine eigene Preview, Config von Hand auf eine fremde Subdomain gestellt
'WEBklar/eigene-seite:.webklar-preview.json': previewJson('olzinger'),
},
})
aw = await startFakeAppwrite({
unique: { websiteProjects: ['repoFullName', 'subdomain'] },
seed: {
websiteProjects: [
{ $id: 'olz', repoFullName: ORIGINAL, projectName: 'Olzinger', subdomain: 'olzinger', customerId: 'c1', ticketId: 't1', status: 'deployed' },
// per Gitea umgezogen (alter Name leitet weiter), Dokument noch unter dem alten Namen
{ $id: 'umzug', repoFullName: 'knso/alt-name', projectName: 'Umzug', subdomain: 'umzug-seite' },
// Repo in Gitea geloescht, Dokument haelt die Subdomain noch
{ $id: 'weg', repoFullName: 'knso/geloescht', projectName: 'Weg', subdomain: 'weg-seite' },
// gehostet ohne Repo (wie die Relaunch-Vorschau)
{ $id: 'rel', repoFullName: '', projectName: 'WEBklar Relaunch', subdomain: 'relaunch' },
{ $id: 'eigen', repoFullName: 'WEBklar/eigene-seite', projectName: 'Eigene Seite', subdomain: 'eigene-seite', status: 'deployed', provisioningStatus: 'ready' },
],
workorders: [{ $id: 't1', woid: '100130', customerName: 'Olzinger', topic: 'Website', status: 'Open' }],
customers: [{ $id: 'c1', name: 'Olzinger GmbH' }],
employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }],
},
})
originalCwd = process.cwd()
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'subdomain-sperre-'))
process.chdir(tmp)
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'test-token',
GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN,
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: '',
TRAEFIK_DYNAMIC_DIR: '',
PUSH_TIME_SECRET: 'w'.repeat(40),
})
const [{ default: giteaWebhookRoutes }, previewDeploy, mailer] = await Promise.all([
import('../server/routes/webhook/gitea.js'),
import('../server/services/previewDeploy.js'),
import('../server/services/mailer.js'),
])
foreignSubdomainOwner = previewDeploy.foreignSubdomainOwner
runDeploy = previewDeploy.runDeploy
foreignSubdomainBlock = previewDeploy.foreignSubdomainBlock
mailer.setMailTransportForTests({ sendMail: async () => ({}) })
const app = express()
app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } }))
app.use('/webhook', giteaWebhookRoutes)
server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
baseUrl = `http://127.0.0.1:${server.address().port}`
})
after(async () => {
if (server) await new Promise((resolve) => server.close(resolve))
if (originalCwd) process.chdir(originalCwd)
await gitea?.close()
await aw?.close()
if (tmp) await fs.rm(tmp, { recursive: true, force: true })
})
test('foreignSubdomainOwner: freie Subdomain und eigenes Repo sind nicht fremd', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'noch-frei'), '')
assert.equal(await foreignSubdomainOwner(ORIGINAL, 'olzinger'), '')
assert.equal(await foreignSubdomainOwner('webklar/OLZINGER', 'olzinger'), '', 'Schreibweise egal')
assert.equal(await foreignSubdomainOwner(KOPIE, ''), '')
})
test('foreignSubdomainOwner: Subdomain eines anderen Repos ist fremd', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'olzinger'), ORIGINAL)
})
test('foreignSubdomainOwner: in Gitea umgezogenes Repo ist nicht fremd, geloeschtes schon', async () => {
assert.equal(await foreignSubdomainOwner('WEBklar/neu-name', 'umzug-seite'), '')
assert.equal(await foreignSubdomainOwner(KOPIE, 'weg-seite'), 'knso/geloescht')
})
test('foreignSubdomainOwner: Projekt ohne Repo haelt seine Subdomain, sperrt sich aber nicht selbst', async () => {
assert.equal(await foreignSubdomainOwner(KOPIE, 'relaunch'), 'Projekt „WEBklar Relaunch“ (ohne Repo)')
assert.equal(await foreignSubdomainOwner('', 'relaunch'), '')
})
test('runDeploy verweigert eine fremde Subdomain, bevor irgendetwas geklont wird', async () => {
const before = gitea.calls.length
const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.deployed, false)
assert.equal(r.blocked, true)
assert.equal(r.reason, 'subdomain_foreign')
assert.equal(r.subdomain, 'olzinger')
assert.equal(r.owner, ORIGINAL)
// nur die Besitzer-Pruefung fragt Gitea (nach dem Original), fuer die Kopie wird nichts geholt
assert.deepEqual(gitea.calls.slice(before).map((c) => `${c.method} ${c.path}`), [`GET /api/v1/repos/${ORIGINAL}`])
})
test('runDeploy: Pruefung nicht moeglich (Appwrite-Fehler) → Deploy laeuft trotzdem', async () => {
aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 })
const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.blocked, undefined)
assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
})
test('foreignSubdomainBlock ohne failOpen (Webhook): Appwrite-Fehler wird weitergereicht', async () => {
aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 })
await assert.rejects(foreignSubdomainBlock(KOPIE, 'olzinger'))
assert.equal((await foreignSubdomainBlock(KOPIE, 'olzinger')).owner, ORIGINAL)
})
test('runDeploy: eigene Subdomain des uebergebenen Projekts und subdomainChecked brauchen keine Abfrage', async () => {
const calls = aw.calls.length
const own = await runDeploy(ORIGINAL, 'main', { subdomain: 'olzinger' }, { project: aw.get('websiteProjects', 'olz') })
assert.equal(own.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
const checked = await runDeploy(KOPIE, 'main', { subdomain: 'olzinger' }, { subdomainChecked: true })
assert.equal(checked.blocked, undefined, 'der Aufrufer hat schon geprueft')
assert.equal(aw.calls.length, calls)
})
test('runDeploy: eigene Subdomain laeuft weiter wie bisher', async () => {
const r = await runDeploy(ORIGINAL, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' })
assert.equal(r.blocked, undefined)
assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
})
test('Webhook: Push einer Kopie mit fremder Subdomain → kein Deploy, Projekt ohne Preview', async () => {
const r = await webhook(pushPayload(KOPIE, { sha: 'aaaa1111' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, '')
assert.equal(r.body.previewUrl, '')
assert.equal(r.body.deploy.blocked, true)
assert.equal(r.body.deploy.owner, ORIGINAL)
const kopie = docOf(KOPIE)
assert.ok(kopie, 'Dokument der Kopie angelegt')
assert.ok(!kopie.subdomain, 'Kopie bekommt die fremde Subdomain nicht')
assert.equal(kopie.status, 'synced')
assert.equal(kopie.projectName, 'HSK-Kopie', 'nicht der Anzeigename aus der fremden Config')
const original = aw.get('websiteProjects', 'olz')
assert.equal(original.subdomain, 'olzinger')
assert.equal(original.status, 'deployed')
})
test('Webhook: Kopie auf anderem Branch bekommt die fremde Subdomain auch nicht', async () => {
const r = await webhook(pushPayload(KOPIE, { sha: 'bbbb2222', branch: 'entwurf' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, '')
assert.ok(!docOf(KOPIE).subdomain)
})
test('Webhook: Kopie mit der Subdomain eines Projekts ohne Repo wird gesperrt', async () => {
const r = await webhook(pushPayload('WEBklar/relaunch-kopie', { sha: 'cccc3333' }))
assert.equal(r.status, 200)
assert.equal(r.body.deploy.blocked, true)
assert.equal(aw.get('websiteProjects', 'rel').subdomain, 'relaunch')
})
test('Webhook: Push des Originals deployt wie bisher', async () => {
const r = await webhook(pushPayload(ORIGINAL, { sha: 'dddd4444' }))
assert.equal(r.status, 200)
assert.equal(r.body.subdomain, 'olzinger')
assert.equal(r.body.deploy.blocked, undefined)
assert.equal(r.body.deploy.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt')
assert.equal(docOf(ORIGINAL).subdomain, 'olzinger')
})
test('Webhook: Projekt mit eigener Preview und fremder Subdomain in der Config bleibt, wie es ist', async () => {
const r = await webhook(pushPayload('WEBklar/eigene-seite', { sha: 'ffff6666' }))
assert.equal(r.status, 200)
assert.equal(r.body.deploy.blocked, true)
const doc = aw.get('websiteProjects', 'eigen')
assert.equal(doc.subdomain, 'eigene-seite')
assert.equal(doc.status, 'deployed')
assert.equal(doc.provisioningStatus, 'ready')
assert.equal(doc.projectName, 'Eigene Seite')
assert.equal(doc.previewState, 'failed', 'WOMS zeigt den Fehlertext nur bei failed')
assert.match(doc.previewError, /„olzinger“, die gehört WEBklar\/Olzinger/)
assert.equal(aw.get('websiteProjects', 'olz').subdomain, 'olzinger')
})
+1 -1
View File
@@ -1030,7 +1030,7 @@ test('funktion-bestellen.js statisch: kein termine*.js, relative Importe, von ke
assert.doesNotMatch(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'), /from '\/funktion-bestellen\.js'|import\('\/funktion-bestellen\.js'\)/, `${datei}: kein absoluter Import`) assert.doesNotMatch(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'), /from '\/funktion-bestellen\.js'|import\('\/funktion-bestellen\.js'\)/, `${datei}: kein absoluter Import`)
} }
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"], 'funktionen.js behält genau diese beiden absoluten Importe') assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"], 'funktionen.js behält genau diese beiden absoluten Importe')
assert.doesNotMatch(fn, /funktion-bestellen/) assert.doesNotMatch(fn, /funktion-bestellen/)
}) })
+2 -2
View File
@@ -311,9 +311,9 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp
assert.match(html, /<section class="kb-view" id="view-termine">[\s\S]*<h1>Terminplaner<\/h1>[\s\S]*<div id="termine-body">/) assert.match(html, /<section class="kb-view" id="view-termine">[\s\S]*<h1>Terminplaner<\/h1>[\s\S]*<div id="termine-body">/)
assert.match(html, /<link rel="stylesheet" href="\/termine\.css">/) assert.match(html, /<link rel="stylesheet" href="\/termine\.css">/)
// R4: funktionen.js lädt keine Terminplaner-Module (die Kachel steht inline); seine absoluten Importe biegt // R4: funktionen.js lädt keine Terminplaner-Module (die Kachel steht inline); seine absoluten Importe biegt
// test/funktionenTiles.test.js um – genau diese beiden (/geraete.js kam mit der Heatmap im Gerätrahmen auf main) // test/funktionenTiles.test.js um – genau diese beiden (die Analyse-Oberfläche liegt in analyse.js, mit relativen Importen)
const fn = lies('public/funktionen.js') const fn = lies('public/funktionen.js')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"]) assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"])
assert.doesNotMatch(fn, /from '\/termine/) assert.doesNotMatch(fn, /from '\/termine/)
// dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b) // dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b)
assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"]) assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
+317 -42
View File
@@ -1,4 +1,5 @@
// Playwright-Check Analyse-Panel (Einschalten-Dialog + aktives Panel): statischer Server fuer public/ + gemockte API. // Playwright-Check Analyse (Cockpit): statischer Server fuer public/ + gemockte API.
// Zustaende: Einschalten, aktiv (volle Daten, neu, wenig Daten, Dev-Modus, alter Collector, langer Pfad, 503), Reiter, Zeitraum.
// Nicht Teil von npm test. Aufruf: node test/ui/analyse.ui.mjs [Ausgabeordner] // Nicht Teil von npm test. Aufruf: node test/ui/analyse.ui.mjs [Ausgabeordner]
import http from 'node:http' import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises' import { readFile, mkdir } from 'node:fs/promises'
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs' import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = process.argv[2] || '/root/reports/website-analyse-2026-10-01' const SHOTS = process.argv[2] || '/root/reports/analyse-ueberarbeitung-2026-10-04/screens'
await mkdir(SHOTS, { recursive: true }) await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' } const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
@@ -22,83 +23,357 @@ const server = http.createServer(async (req, res) => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}` const base = `http://127.0.0.1:${server.address().port}`
/* --- Testdaten: Form wie analytics/src/stats.js, Werte wie die Fixture "Boulderhalle Überhang" --- */
const legal = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' } const legal = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }
const controller = { ok: true, company: 'Bäckerei Berg GmbH', address: 'Hauptstraße 1, 12345 Musterstadt', email: 'info@baeckerei-berg.example' } const controller = { ok: true, company: 'Bäckerei Berg GmbH', address: 'Hauptstraße 1, 12345 Musterstadt', email: 'info@baeckerei-berg.example' }
const base1 = { projectId: 'p1', projectName: 'Bäckerei Berg', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } } const baseProject = { projectId: 'p1', projectName: 'Boulderhalle Überhang', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } }
const analyse = (over) => ({ state: 'active', eligible: true, devMode: false, accentColor: '#1d4ed8', avvVersion: '2026-10-02', activatedAt: '2026-08-12T09:00:00.000Z', legal, controller, ...over })
const project = (over = {}) => ({ ...baseProject, analyse: analyse(over) })
const TODAY = '2026-10-04'
const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10)
const daysOf = (from, n, base, spike = -1) => Array.from({ length: n }, (_, i) => ({
day: addDays(from, i), pv: i === spike ? 371 : Math.round(base * (1 + 0.25 * Math.sin(i / 2.3))), visitors: 80 + (i % 7) * 4, contacts: 4 + (i % 5), avgSec: 100 + (i % 9) * 3,
}))
const TOTALS = {
7: { pv: 1640, visitors: 560, contacts: 41, consentRate: 0.61, engaged: 400, avgSec: 101, avgScroll: 62 },
30: { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 },
90: { pv: 19800, visitors: 6700, contacts: 530, consentRate: 0.6, engaged: 5000, avgSec: 111, avgScroll: 63 },
}
const BEFORE = {
7: { pv: 1500, visitors: 520, contacts: 35, consentRate: 0.6, engaged: 380, avgSec: 99, avgScroll: 60 },
30: { pv: 6080, visitors: 2210, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 },
90: { pv: 17900, visitors: 6200, contacts: 470, consentRate: 0.6, engaged: 4500, avgSec: 112, avgScroll: 61 },
}
const SCALE = { 7: 0.25, 30: 1, 90: 3 } // Anteil der 30-Tage-Zahlen je Zeitraum
const PAGES = [['/', 2480], ['/preise', 1310], ['/kurse', 1150], ['/routen', 960], ['/oeffnungszeiten', 640], ['/team', 410], ['/kontakt', 300], ['/impressum', 120], ['/datenschutz', 90]]
// Der Zeitraum endet gestern (abgeschlossene Tage); heute steht getrennt in `today`
function makeStats(range, over = {}) {
const from = addDays(TODAY, -range)
const pfrom = addDays(from, -range)
return {
range, from, to: addDays(TODAY, -1), firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)),
today: { day: TODAY, pv: 42, visitors: 17, contacts: 2 },
pages: PAGES.map(([key, count]) => ({ key, count: Math.round(count * SCALE[range]) })),
refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }].map((r) => ({ ...r, count: Math.round(r.count * SCALE[range]) })),
countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: 'CH', count: 330 }],
devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2030 }, { key: 't', count: 310 }],
contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 41 }, { key: 'wa', count: 38 }, { key: 'out', count: 13 }],
banner: { shown: 3950, accept: 2410, reject: 1540 },
previous: {
from: pfrom, to: addDays(from, -1), complete: true, totals: BEFORE[range], series: daysOf(pfrom, range, 200),
contacts: [{ key: 'tel', count: 75 }, { key: 'mail', count: 32 }, { key: 'wa', count: 21 }, { key: 'out', count: 13 }],
},
...over,
}
}
const longPath = `/${'a'.repeat(120)}`
const OLD = (range) => ({
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: daysOf(addDays(TODAY, -13), 14, 40).map(({ day, pv }) => ({ day, pv })),
pages: [{ key: '/', count: 300 }], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 10, accept: 6, reject: 4 }, range,
})
// je Zustand: die Website und die Statistik (nach range)
const SCENARIOS = { const SCENARIOS = {
off: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } }, off: { project: project({ state: 'off' }) },
missing: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } } }, missing: { project: project({ state: 'off', controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } }) },
active: { ...base1, analyse: { state: 'active', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } }, full: { project: project(), stats: (r) => makeStats(r) },
} neu: { project: project(), stats: (r) => makeStats(r, { firstDay: addDays(TODAY, -12), previous: { ...makeStats(r).previous, complete: false } }) },
const days = Array.from({ length: 14 }, (_, i) => ({ day: `2026-09-${String(17 + i).padStart(2, '0')}`, pv: 20 + ((i * 7) % 13) * 4 })) wenig: { project: project(), stats: (r) => makeStats(r, { totals: { ...TOTALS[r], pv: 12 } }) },
const stats = { dev: { project: project({ devMode: true }), stats: (r) => makeStats(r) },
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: days, alt: { project: project(), stats: (r) => OLD(r) },
pages: [{ key: '/', count: 300 }, { key: '/kontakt', count: 120 }], refs: [{ key: '', count: 200 }, { key: 'google.com', count: 90 }], stark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 2400, Math.min(14, r - 1)) }) },
countries: [{ key: 'DE', count: 500 }], devices: [{ key: 'm', count: 380 }, { key: 'd', count: 230 }], contacts: [{ key: 'tel', count: 12 }], sehrStark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 12000, Math.min(14, r - 1)) }) },
banner: { shown: 300, accept: 186, reject: 114 }, langerPfad: { project: project(), stats: (r) => makeStats(r, { pages: [{ key: longPath, count: 900 }, { key: '/kurz', count: 500 }], refs: [{ key: `${'h'.repeat(90)}.example.com`, count: 300 }] }) },
fehler: { project: project(), stats: null },
zwei: { projects: [project(), { ...baseProject, projectId: 'p2', projectName: 'Zweite Website mit einem wirklich sehr langen Namen, der nicht in eine Zeile passt', analyse: analyse({}) }], stats: (r) => makeStats(r) },
} }
let scenario = 'off' let scenario = 'off'
const browser = await chromium.launch() let statsRequests = []
let delayFirst30 = 0
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const launch = () => chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' }))
const browser = await launch()
let failed = 0 let failed = 0
const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 } const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 }
const TIMEOUT = Number(process.env.UI_TIMEOUT) || 120000
try { try {
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) { for (const [label, viewport, touch] of [['desktop', { width: 1440, height: 1000 }, false], ['phone', { width: 402, height: 874 }, true]]) {
const ctx = await browser.newContext({ viewport }) const ctx = await browser.newContext({ viewport, hasTouch: touch })
ctx.setDefaultTimeout(TIMEOUT)
const page = await ctx.newPage() const page = await ctx.newPage()
const errors = [] const errors = []
page.on('pageerror', (e) => errors.push(e.message)) page.on('pageerror', (e) => errors.push(e.message))
await page.route('**/api/**', async (route) => { await page.route('**/api/**', async (route) => {
const p = new URL(route.request().url()).pathname const url = new URL(route.request().url())
const json = (b) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) }) const p = url.pathname
const json = (b, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(b) }).catch(() => {})
const sc = SCENARIOS[scenario]
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } }) if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] }) if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Boulderhalle Überhang', status: 'live' }] })
if (p === '/api/features') return json({ features: [] }) if (p === '/api/features') return json({ features: [] })
if (p === '/api/activity') return json({ tickets: [] }) if (p === '/api/activity') return json({ tickets: [] })
if (p === '/api/funktionen') return json({ projects: [SCENARIOS[scenario]] }) if (p === '/api/funktionen') return json({ projects: sc.projects || [sc.project] })
if (p === '/api/funktionen/analyse/stats') return json(stats) if (p === '/api/funktionen/analyse/stats') {
const range = Number(url.searchParams.get('range'))
statsRequests.push(range)
if (range === 30 && delayFirst30 > 0) {
delayFirst30 -= 1
await sleep(1500)
}
if (!sc.stats) return json({ error: 'Auswertung nicht erreichbar' }, 503)
return json(sc.stats(range))
}
if (p === '/api/funktionen/analyse/heatmap-link') return json({ url: `${base}/offline.html` })
if (p === '/api/funktionen/analyse/deactivate') return json({ ok: true }) if (p === '/api/funktionen/analyse/deactivate') return json({ ok: true })
return json({}) return json({})
}) })
const open = async (sc) => { // waitFor: Selektor, der den fertigen Zustand anzeigt
const open = async (sc, waitFor = '.an-ov:not(.an-skeleton)') => {
scenario = sc scenario = sc
statsRequests = []
await page.goto('about:blank') await page.goto('about:blank')
await page.goto(`${base}/dashboard.html#analyse`) await page.goto(`${base}/dashboard.html#analyse`)
await page.waitForSelector('#analyse-body .kb-panel') await page.waitForSelector('#analyse-body .an')
await page.waitForTimeout(900) await page.waitForSelector(waitFor)
await page.waitForTimeout(300)
} }
const shot = async (name) => { const shot = async (name) => {
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 3200)) const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 4200))
await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) }) await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) })
await page.waitForTimeout(150) await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/task-7-${label}-${name}.png` }) await page.screenshot({ path: `${SHOTS}/${label}-${name}.png` })
await page.setViewportSize(viewport) await page.setViewportSize(viewport)
} }
const noScroll = async (n) => check(`${label} kein_horizontaler_scroll ${n}`, await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)) const noScroll = () => page.evaluate(() => document.scrollingElement.scrollWidth <= window.innerWidth)
const text = (sel) => page.evaluate((s) => document.querySelector(s)?.textContent ?? '', sel)
const count = (sel) => page.evaluate((s) => document.querySelectorAll(s).length, sel)
const L = (name) => `${label} ${name}`
await open('off') /* --- Einschalten (wie bisher) --- */
const text = await page.textContent('#analyse-body') await open('off', '#an-on')
check(`${label} verantwortlicher`, text.includes('Bäckerei Berg GmbH') && text.includes('info@baeckerei-berg.example')) const body = await text('#analyse-body')
check(`${label} links`, (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`) const linkOk = (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`
check(`${label} knopf_gesperrt_ohne_haken`, await page.isDisabled('#an-on')) const disabledFirst = await page.isDisabled('#an-on')
const preview = (await text('#an-preview')).includes('Annehmen') && (await text('#an-preview')).includes('Ablehnen')
await page.check('#an-avv') await page.check('#an-avv')
check(`${label} knopf_frei_mit_haken`, !(await page.isDisabled('#an-on'))) const enabled = !(await page.isDisabled('#an-on'))
await noScroll('einschalten') check(L('Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher)'), body.includes('Bäckerei Berg GmbH') && body.includes('info@baeckerei-berg.example') && linkOk && preview && disabledFirst && enabled)
check(L('Einschalten: Kopfleiste ohne Zeitraum und Reiter, Status "Analyse ist noch ausgeschaltet"'), (await count('.an-top')) === 1 && (await count('.an-seg')) === 0 && (await count('.an-tabs')) === 0 && (await text('.an-status')).includes('Analyse ist noch ausgeschaltet'))
check(L('kein_horizontaler_scroll einschalten'), await noScroll())
await shot('einschalten') await shot('einschalten')
await open('missing') await open('missing', '#an-missing')
await page.check('#an-avv') await page.check('#an-avv')
check(`${label} gesperrt_ohne_angaben`, (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing'))) check(L('gesperrt_ohne_angaben'), (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing')))
await shot('fehlende-angaben')
await open('active') /* --- aktiv, volle Daten --- */
await page.waitForSelector('.an-kpis') await open('full')
await noScroll('aktiv') check(L('aktiv: Block "Das Wichtigste" mit "14 % mehr"'), (await text('.an-sum')).includes('Das Wichtigste') && (await text('.an-sum')).includes('14 % mehr'))
check(L('aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼'), (await count('.an-kpis .an-kpi')) === 4 && (await count('.an-kpis .an-delta.up')) === 3 && (await count('.an-kpis .an-delta.down')) === 1 && (await text('.an-kpis')).includes('▲') && (await text('.an-kpis')).includes('▼'))
check(L('aktiv: Kopfleiste mit "Heute bisher: 42 Aufrufe" und Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /^Analyse aktiv · Heute bisher: 42 Aufrufe · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')) && (await page.getAttribute('.an-seg .on', 'data-range')) === '30' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht')
check(L('kein_horizontaler_scroll aktiv'), await noScroll())
// Raster: Desktop 4 Kacheln nebeneinander, Handy 2 x 2; Diagramm und Kontaktkarte gleich hoch (Desktop)
const tiles = await page.$$eval('.an-kpis .an-kpi', (els) => els.map((e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width) } }))
check(L(touch ? 'Kacheln 2 × 2' : 'Kacheln 4 nebeneinander'), touch
? tiles[0].y === tiles[1].y && tiles[2].y === tiles[3].y && tiles[2].y > tiles[0].y && tiles[0].x === tiles[2].x && tiles[1].x > tiles[0].x
: tiles.every((t) => t.y === tiles[0].y))
await page.evaluate(() => document.querySelector('.an-chart').scrollIntoView({ block: 'center' }))
await page.waitForTimeout(200)
const boxes = await page.evaluate(() => {
const r = (s) => { const b = document.querySelector(s).getBoundingClientRect(); return { top: Math.round(b.top), height: Math.round(b.height), left: b.left, right: b.right } }
return { chart: r('.an-chartbox'), contacts: r('.an-contacts'), svg: r('.an-chart') }
})
if (!touch) {
check(L(`Diagramm und Kontaktkarte gleich hoch (${boxes.chart.height} / ${boxes.contacts.height} px)`), boxes.chart.top === boxes.contacts.top && Math.abs(boxes.chart.height - boxes.contacts.height) <= 1)
check(L(`Diagramm füllt seine Karte (${boxes.svg.height} von ${boxes.chart.height} px)`), boxes.svg.height >= boxes.chart.height * 0.6)
} else {
check(L('Handy: Diagramm und Kontaktkarte untereinander'), boxes.contacts.top > boxes.chart.top && boxes.chart.left === boxes.contacts.left)
}
// Blase am Diagramm: nächster Tag, zeigt "…: N Aufrufe (davor M)"; Maus verlässt / Tippen daneben blendet aus
const svg = boxes.svg
const px = svg.left + (svg.right - svg.left) * 0.62
const py = svg.top + svg.height / 2
if (touch) {
await page.touchscreen.tap(px, py)
} else {
await page.mouse.move(px, py)
}
await page.waitForSelector('.an-tip:not([hidden])')
const tip = await text('.an-tip')
check(L(`Diagramm-Blase: "${tip}"`), /^\S+, \d\d\.\d\d\.: [\d.]+ Aufrufe \(davor [\d.]+\)$/.test(tip) && (await count('.an-dot.on')) === 1)
if (touch) {
// neben das Diagramm tippen (nicht in die App-Leiste oben, die führt zurück)
const h = await page.locator('.an-chartbox h3').boundingBox()
await page.touchscreen.tap(h.x + 10, h.y + h.height / 2)
} else {
await page.mouse.move(2, 2)
}
await page.waitForFunction(() => document.querySelector('.an-tip')?.hidden === true)
check(L('Diagramm-Blase: verschwindet wieder, kein Punkt bleibt markiert'), (await count('.an-dot.on')) === 0)
// Achsenbeschriftung: gerendert ca. 11-12 px (viewBox 640 skaliert mit der Breite), nichts ragt aus der Karte, x-Beschriftungen überlappen nicht
const axis = () => page.evaluate(() => {
const svg = document.querySelector('.an-chart')
const scale = svg.getBoundingClientRect().width / 640
const box = document.querySelector('.an-chartbox').getBoundingClientRect()
const texts = [...svg.querySelectorAll('.an-axis')].map((t) => ({ r: t.getBoundingClientRect(), px: parseFloat(getComputedStyle(t).fontSize) * scale, anchor: t.getAttribute('text-anchor'), s: t.textContent }))
const xs = texts.filter((t) => t.anchor === 'middle').sort((a, b) => a.r.left - b.r.left)
return {
px: Math.min(...texts.map((t) => t.px)), pxMax: Math.max(...texts.map((t) => t.px)),
inside: texts.every((t) => t.r.left >= box.left + 1 && t.r.right <= box.right - 1),
gap: xs.every((t, i) => !i || t.r.left >= xs[i - 1].r.right + 4),
y: texts.filter((t) => t.anchor === 'end').map((t) => t.s).join(','),
}
})
const ax = await axis()
check(L(`Achsenbeschriftung ca. 11-12 px (${ax.px.toFixed(1)} px), liegt in der Karte, x-Werte überlappen nicht`), ax.px >= 10.5 && ax.pxMax <= 13 && ax.inside && ax.gap)
const faint = await page.evaluate(() => getComputedStyle(document.querySelector('.an-credit')).color)
check(L(`Hilfstext "--an-faint" kontrastreich (${faint})`), Number(/rgba?\([^)]*?,\s*([\d.]+)\)$/.exec(faint)?.[1] ?? 1) >= 0.55)
if (touch) {
const heights = await page.evaluate(() => Object.fromEntries(['.an-tabbtn', '.an-seg button', '.an-ltab', '.an-more', '.an-teaser .an-btn'].map((sel) => [sel, Math.min(...[...document.querySelectorAll(sel)].map((e) => e.getBoundingClientRect().height))])))
check(L(`Tippflächen mindestens 40 px: ${Object.entries(heights).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(', ')}`), Object.values(heights).every((h) => h >= 39.5))
}
// Ranglisten: Umschalter und "Alle anzeigen" ändern nur ihre Karte
check(L('Rangliste: 6 Zeilen und "Alle anzeigen"'), (await count('[data-rank="seen"] .an-lr')) === 6 && (await count('[data-rank="seen"] [data-rank-more]')) === 1)
await page.click('[data-rank="seen"] [data-rank-more]')
check(L('Rangliste: "Alle anzeigen" zeigt alle 9 Seiten'), (await count('[data-rank="seen"] .an-lr')) === 9 && (await count('[data-rank="seen"] [data-rank-more]')) === 0)
await page.click('[data-rank="seen"] [data-rank-tab="refs"]')
check(L('Rangliste: Umschalter Herkunft, Seiten-Reiter der anderen Karte unverändert'), (await page.getAttribute('[data-rank="seen"]', 'data-rank-active')) === 'refs' && (await page.getAttribute('[data-rank="who"]', 'data-rank-active')) === 'devices' && (await text('[data-rank="seen"]')).includes('Google'))
await shot('aktiv') await shot('aktiv')
// Reiter per Pfeiltaste (Reihenfolge Übersicht, Heatmap, Einstellungen)
await page.focus('.an-tabbtn[data-tab="uebersicht"]')
await page.keyboard.press('ArrowRight')
await page.waitForSelector('.an-tabbtn[data-tab="heatmap"][aria-selected="true"]')
await page.keyboard.press('ArrowLeft')
await page.waitForSelector('.an-tabbtn[data-tab="uebersicht"][aria-selected="true"]')
check(L('Reiter: Pfeiltasten wechseln die Reiter'), (await count('.an-ov:not(.an-skeleton) .an-kpis')) === 1)
// Zeitraum 7: neue Anfrage, nur die Übersicht lädt neu, Reiter bleibt
const before = statsRequests.length
await page.click('.an-seg [data-range="7"]')
await page.waitForFunction(() => document.querySelector('.an-lead')?.textContent.includes('7 Tagen'))
check(L('Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht'), statsRequests.length > before && statsRequests.at(-1) === 7 && (await page.getAttribute('.an-seg .on', 'data-range')) === '7' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht')
// Heatmap: iframes erst nach dem Reiterwechsel (Teaser-Knopf und Reiterknopf wechseln beide)
await open('full')
const iframesBefore = await count('iframe')
await page.click('.an-teaser [data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Teaser "Öffnen" wechselt in den Reiter Heatmap'), (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'heatmap' && (await count('.an-top .an-seg')) === 0)
await open('full')
await page.click('.an-tabbtn[data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes'), iframesBefore === 0 && (await count('.hs-dev iframe')) === 2 && (await count('#an-heat-page')) === 1)
check(L('Heatmap: Seitenauswahl und Computer/Tablet-Schalter in einer Leiste'), await page.evaluate(() => { const bar = document.querySelector('.an-heat-bar'); return Boolean(bar?.querySelector('#an-heat-page') && bar.querySelector('.hs-switch button')) }))
check(L('kein_horizontaler_scroll heatmap'), await noScroll())
await page.waitForTimeout(600)
await shot('heatmap')
// zurück in die Übersicht: die iframes verschwinden, die Zahlen stehen sofort wieder da
await page.click('.an-tabbtn[data-tab="uebersicht"]')
await page.waitForSelector('.an-ov:not(.an-skeleton) .an-kpis')
check(L('Reiterwechsel zurück: Übersicht ohne iframes'), (await count('iframe')) === 0)
// Einstellungen
await page.click('.an-tabbtn[data-tab="einstellungen"]')
await page.waitForSelector('#an-off-ask')
const settings = await text('#analyse-body')
check(L('Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"'), (await page.isVisible('#an-off-ask')) && settings.includes('Analyse ausschalten') && /aktiv seit 12\.08\.2026/.test(settings) && settings.includes('Fassung 2026-10-02') && (await count('.an-docs a')) === 2 && (await count('.an-swatch')) === 1 && (await count('.an-top .an-seg')) === 0)
check(L('kein_horizontaler_scroll einstellungen'), await noScroll())
const errColor = await page.evaluate(() => {
const p = document.createElement('p')
p.className = 'error'
p.textContent = 'x'
document.querySelector('#an-off').appendChild(p)
const c = getComputedStyle(p).color
p.remove()
return c
})
check(L(`Fehlertext beim Ausschalten rot (${errColor})`), errColor === 'rgb(252, 165, 165)')
await shot('einstellungen')
await page.click('#an-off-ask') await page.click('#an-off-ask')
await page.click('#an-off-yes') await page.click('#an-off-yes')
await page.waitForSelector('#an-off-done') await page.waitForSelector('#an-off-done')
check(`${label} ausschalten_bestaetigung`, (await page.textContent('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert')) check(L('ausschalten_bestaetigung'), (await text('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert'))
await shot('ausgeschaltet') await shot('ausgeschaltet')
check(`${label} keine_js_fehler`, errors.length === 0)
// verzögerte erste Antwort überschreibt die spätere nicht
delayFirst30 = 1
await open('full', '.an-seg')
await page.click('.an-seg [data-range="7"]')
await page.waitForFunction(() => document.querySelector('.an-lead')?.textContent.includes('7 Tagen'))
await page.waitForTimeout(1900)
const lateOk = (await text('.an-lead')).includes('7 Tagen') && !(await text('.an-lead')).includes('30 Tagen') && statsRequests.join() === '30,7' && (await page.getAttribute('.an-seg .on', 'data-range')) === '7'
// auch der Zustand dahinter bleibt der 7-Tage-Stand: "Alle anzeigen" und der Umschalter malen mit 7-Tage-Zahlen (Startseite 620, Google 220)
const firstRow = (card) => text(`[data-rank="${card}"] .an-lr`)
const before7 = await firstRow('seen')
await page.click('[data-rank="seen"] [data-rank-more]')
const more = await firstRow('seen')
await page.click('[data-rank="seen"] [data-rank-tab="refs"]')
const refs = await firstRow('seen')
check(L('verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht'), lateOk && before7.includes('620') && more.includes('620') && !more.includes('2.480') && refs.includes('220') && !refs.includes('880') && (await count('[data-rank="seen"] .an-lr')) === 4)
delayFirst30 = 0
// Heatmap-Reiter während der ersten Übersicht-Anfrage: nutzt sie mit, statt dieselbe Anfrage noch einmal zu stellen
delayFirst30 = 1
await open('full', '.an-seg')
await page.click('.an-tabbtn[data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Heatmap-Reiter nutzt die laufende 30-Tage-Anfrage mit'), statsRequests.join() === '30')
delayFirst30 = 0
// Website wechseln: Reiter bleibt
await open('zwei')
await page.click('.an-tabbtn[data-tab="einstellungen"]')
await page.waitForSelector('#an-off-ask')
check(L('mehrere Websites: Auswahlfeld in der Kopfleiste, langer Name bricht nichts'), (await count('#an-project option')) === 2 && (await noScroll()))
await page.selectOption('#an-project', 'p2')
await page.waitForSelector('#an-off-ask')
check(L('Websitewechsel: Reiter Einstellungen bleibt'), (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'einstellungen' && (await page.inputValue('#an-project')) === 'p2')
await shot('zwei-websites')
/* --- weitere Zustände --- */
await open('neu')
check(L('neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse"'), (await count('.an-kpis .an-delta')) === 0 && (await text('.an-kpis')).includes('Vergleich ab') && (await text('.an-sum')).includes('Seit dem Start der Analyse') && (await count('.an-prev')) === 0)
await shot('neu')
await open('wenig')
check(L('wenig: Leerzustand'), (await count('.an-sum-empty')) === 1 && (await text('.an-sum')).includes('Sobald ein paar mehr Besuche da sind'))
await shot('wenig')
await open('dev')
check(L('dev: "Im Dev-Modus wird nicht gemessen."'), (await text('.an-status')).includes('Im Dev-Modus wird nicht gemessen.') && (await page.getAttribute('.an-site', 'data-state')) === 'warn')
await shot('dev')
await open('alt')
check(L('alt: kein "Heute bisher" (alter Collector), Stand bleibt'), /^Analyse aktiv · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')))
check(L('alt: keine Chips, keine pageerror'), (await count('.an-delta')) === 0 && (await count('.an-prev')) === 0 && errors.length === 0 && (await text('.an-kpis')).includes('612'))
await shot('alt')
await open('fehler', '.an-error')
const requestsBefore = statsRequests.length
check(L('fehler: "Auswertung gerade nicht erreichbar" + Neu laden'), (await text('.an-error')).includes('Auswertung gerade nicht erreichbar') && (await text('#an-retry')) === 'Neu laden' && (await count('.an-tabs')) === 1)
await page.click('#an-retry')
for (let i = 0; i < 50 && statsRequests.length === requestsBefore; i += 1) await sleep(100)
await page.waitForSelector('.an-error')
check(L('fehler: "Neu laden" fragt erneut an'), statsRequests.length === requestsBefore + 1)
await shot('fehler')
for (const [sc, label5] of [['stark', 'vierstellige'], ['sehrStark', 'fünfstellige']]) {
await open(sc)
const big = await axis()
check(L(`${label5} y-Werte (${big.y}) nicht abgeschnitten, x-Werte überlappen nicht, Schrift ${big.px.toFixed(1)} px`), big.inside && big.gap && big.px >= 10.5 && big.pxMax <= 13)
await shot(sc === 'stark' ? 'skala-4-stellig' : 'skala-5-stellig')
}
await open('langerPfad')
const name = await page.evaluate(() => { const n = document.querySelector('[data-rank="seen"] .an-name'); return { sw: n.scrollWidth, cw: n.clientWidth, title: n.title } })
check(L(`langer Pfad${touch ? ', 402 px' : ''}: document.scrollingElement.scrollWidth <= innerWidth`), (await noScroll()) && name.sw > name.cw && name.title.length > 100)
await shot('langer-pfad')
check(L('keine pageerror in allen Zuständen'), errors.length === 0)
if (errors.length) console.log(errors.join('\n'))
await ctx.close() await ctx.close()
} }
} finally { } finally {