Analyse: Cockpit-Übersicht mit Reitern Übersicht, Heatmap, Einstellungen

analyse.js setzt die Bausteine aus analyse-cards.js zusammen: Kopfleiste mit
Stand und Zeitraum, Reiter (Pfeiltasten), Übersicht mit Ladegerüst und
Fehlerkarte, Diagramm-Blase, Ranglisten-Umschalter, Heatmap erst beim Öffnen
des Reiters, Einstellungen. Einschalten, gesperrt, nicht verfügbar und
unbekannt erscheinen im selben Rahmen, Texte unverändert.

analyse.css (neu) trägt alle Analyse-Regeln unter .an mit eigenem türkisen
Akzent; der Analyse-Abschnitt in funktionen.css entfällt. Der Client-Test des
Banners liest die Banner-Regeln jetzt aus analyse.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 21:34:43 +00:00
1 parent 1964e5fcca
commit a9f0711e97
6 files changed
+763 -222

No files matched your search

+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\{[^}]*[; ]width:150px/);
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.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel');
assert.equal(host.style.zIndex, '2147483647');
+279
View File
@@ -0,0 +1,279 @@
/* 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.4);
--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-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; }
/* 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; }
.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; }
.an-axis { fill: var(--an-muted); font-size: 13px; }
.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; }
/* ------------------------------------------- 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; }
.an-tabs { gap: 20px; margin: 14px 2px; }
.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-axis { font-size: 17px; }
.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; }
}
+225 -99
View File
@@ -1,16 +1,22 @@
/* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen).
initAnalyse(rootEl, { projectId }): je nach Zustand der Analyse Einschalten (off), Hinweis (disabled/blocked) oder Auswertung (active).
Reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: funktionen.css.
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, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from './analyse-view.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'
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.'
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
@@ -18,32 +24,36 @@ export async function initAnalyse(root, { 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>'
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
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' ? ' <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></div>`
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="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></section>`
else renderAnalyseActivation(stage, project, reload)
}
// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens
function headHtml(project, usable, { showRange, stamp = '' } = {}) {
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 })
}
function bannerPreview(color) {
const c = bannerColor(color)
const btn = `style="background:${c};border-color:${c};color:#fff"`
@@ -68,7 +78,7 @@ export function renderAnalyseActivation(stage, project, reload) {
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">
<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>
@@ -112,76 +122,194 @@ export function renderAnalyseActivation(stage, project, reload) {
})
}
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))}`
/* --- 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
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
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
}
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 || [])
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 })
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}`)
}
async function loadStats(range) {
const stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
state.stats = stats
state.range = range
state.stamp = CLOCK.format(new Date())
return stats
}
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()
try {
await loadStats(range)
} catch (err) {
if (!current(mine)) return
stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
return
}
if (!current(mine)) return
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>'
try {
await loadStats(30)
} catch (err) {
if (!current(mine)) return
stage.innerHTML = errorHtml(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)
return
}
if (!current(mine)) return
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.
@@ -264,7 +392,7 @@ async function loadHeatDevice(fig, projectId, vp, path) {
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)
if (run === fig.heatRun && fig.isConnected && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path)
}, HEAT_TIMEOUT_MS)
}
@@ -286,7 +414,7 @@ async function showHeatFallback(fig, projectId, vp, path) {
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>'
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) {
@@ -295,13 +423,11 @@ function renderAnalyseHeat(box, projectId, pages) {
}
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>
<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-row">
<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>
@@ -324,7 +450,7 @@ function renderAnalyseHeat(box, projectId, pages) {
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.classList.toggle('on', on)
x.setAttribute('aria-pressed', String(on))
})
stage.dataset.big = heatSel.big
@@ -351,7 +477,7 @@ export function renderAnalyseOff(box, projectId, reload) {
<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>`
<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', () => {
+2 -1
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css">
<link rel="stylesheet" href="/funktionen.css">
<link rel="stylesheet" href="/analyse.css">
<link rel="stylesheet" href="/termine.css">
</head>
<body class="portal-page dashboard-page kb-page">
@@ -177,7 +178,7 @@
<div id="news-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Analyse: Einschalten oder Auswertung (funktionen.js, initAnalyse) -->
<!-- Analyse: Einschalten oder Auswertung (analyse.js, initAnalyse) -->
<section class="kb-view" id="view-analyse">
<header class="kb-head">
<h1>Analyse</h1>
-79
View File
@@ -137,82 +137,3 @@
.fn-tiles { grid-template-columns: minmax(0, 1fr); }
.fn-facts dt { min-width: 0; flex: 1 0 100%; }
}
/* ------------------------------------------------------------- Analyse --- */
#view-analyse .kb-panel { max-width: none; }
#view-analyse .an-panel { max-width: 720px; }
#view-analyse .fn-project { max-width: 640px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
#view-analyse .kb-panel + .kb-panel { margin-top: 16px; }
.an-panel p { margin: 0 0 12px; line-height: 1.55; }
.an-h4 { margin: 18px 0 8px; font-size: 14px; }
.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: transparent; cursor: pointer; }
.an-presets { display: flex; flex-wrap: wrap; gap: 8px; }
.an-preset { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--kb-border-strong); cursor: pointer; padding: 0; }
.an-hint { margin: 10px 0 0; font-size: 13px; color: #fcd34d; }
/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */
.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
.an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; }
.an-bn-link { text-decoration: underline; }
.an-bn-row { display: flex; gap: 10px; flex: none; }
.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 150px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
.an-range { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.an-tab { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.an-tab.active { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.an-kpi { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); min-width: 0; }
.an-kpi span { display: block; font-size: 13px; color: var(--kb-muted); }
.an-kpi b { display: block; margin-top: 4px; font-size: 26px; font-variant-numeric: tabular-nums; }
.an-chart-box { margin-bottom: 16px; }
.an-chart { display: block; width: 100%; height: auto; }
.an-grid { stroke: var(--kb-border-strong); stroke-width: 1; }
.an-axis { fill: var(--kb-muted); font-size: 11px; }
.an-line { fill: none; stroke: var(--kb-lilac); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.an-area { fill: var(--kb-purple-soft); stroke: none; }
.an-dot { fill: var(--kb-lilac); }
.an-grid-lists { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; align-items: start; }
#view-analyse .an-grid-lists .kb-panel { margin-top: 0; }
.an-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.an-row-top { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
.an-row-label { min-width: 0; overflow-wrap: anywhere; }
.an-row-top b { font-variant-numeric: tabular-nums; }
.an-bar { height: 6px; margin-top: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.07); overflow: hidden; }
.an-bar i { display: block; height: 100%; border-radius: 3px; background: var(--kb-purple); }
.an-note, .an-credit { margin: 16px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--kb-muted); }
.an-credit { font-size: 11.5px; color: var(--kb-faint); }
.an-credit a { color: inherit; }
.an-heat { margin-top: 16px; }
.an-heat p, .an-off p { margin: 0 0 12px; line-height: 1.55; }
.an-heat-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; }
.an-heat-row label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; flex: 1 1 200px; min-width: 0; }
.an-heat-row select { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; min-width: 0; }
.an-heat-row select option { color: #111; }
/* Heatmap-Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */
.an-heat .hs-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.an-heat .hs-head h3 { margin: 0; }
.hs-switch { display: flex; gap: 8px; }
.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; }
.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; }
.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; }
.hs-frame { width: 100%; }
.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--kb-muted); text-align: center; }
.hs-dev .dm-canvas { background: #fff; }
.hs-site { position: absolute; left: 0; background: #fff; }
.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
@media (max-width: 640px) {
.hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; }
.hs-dev--phone .hs-frame { width: 62%; }
}
.an-off { margin-top: 16px; }
.an-ghost { padding: 9px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
.an-ghost:hover { background: rgba(255, 255, 255, 0.07); }
.an-danger { color: #fca5a5; }
@media (max-width: 880px) {
.an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.an-grid-lists { grid-template-columns: minmax(0, 1fr); }
.an-bn { display: block; margin: 12px 0; padding: 16px; }
.an-bn-row { flex-direction: column; margin-top: 12px; }
.an-bn-btn { width: 100%; }
}
+256 -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]
import http from 'node:http'
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'
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 })
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) => {
@@ -22,83 +23,296 @@ const server = http.createServer(async (req, res) => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
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 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 PAGES = [['/', 2480], ['/preise', 1310], ['/kurse', 1150], ['/routen', 960], ['/oeffnungszeiten', 640], ['/team', 410], ['/kontakt', 300], ['/impressum', 120], ['/datenschutz', 90]]
function makeStats(range, over = {}) {
const from = addDays(TODAY, -(range - 1))
const pfrom = addDays(from, -range)
return {
range, from, to: TODAY, firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)),
pages: PAGES.map(([key, count]) => ({ key, count })),
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: '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 = {
off: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
missing: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } } },
active: { ...base1, analyse: { state: 'active', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
}
const days = Array.from({ length: 14 }, (_, i) => ({ day: `2026-09-${String(17 + i).padStart(2, '0')}`, pv: 20 + ((i * 7) % 13) * 4 }))
const stats = {
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: days,
pages: [{ key: '/', count: 300 }, { key: '/kontakt', count: 120 }], refs: [{ key: '', count: 200 }, { key: 'google.com', count: 90 }],
countries: [{ key: 'DE', count: 500 }], devices: [{ key: 'm', count: 380 }, { key: 'd', count: 230 }], contacts: [{ key: 'tel', count: 12 }],
banner: { shown: 300, accept: 186, reject: 114 },
off: { project: project({ state: 'off' }) },
missing: { project: project({ state: 'off', controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } }) },
full: { project: project(), stats: (r) => makeStats(r) },
neu: { project: project(), stats: (r) => makeStats(r, { firstDay: addDays(TODAY, -12), previous: { ...makeStats(r).previous, complete: false } }) },
wenig: { project: project(), stats: (r) => makeStats(r, { totals: { ...TOTALS[r], pv: 12 } }) },
dev: { project: project({ devMode: true }), stats: (r) => makeStats(r) },
alt: { project: project(), stats: (r) => OLD(r) },
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'
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
const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 }
const TIMEOUT = Number(process.env.UI_TIMEOUT) || 120000
try {
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
const ctx = await browser.newContext({ viewport })
for (const [label, viewport, touch] of [['desktop', { width: 1440, height: 1000 }, false], ['phone', { width: 402, height: 874 }, true]]) {
const ctx = await browser.newContext({ viewport, hasTouch: touch })
ctx.setDefaultTimeout(TIMEOUT)
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.route('**/api/**', async (route) => {
const p = new URL(route.request().url()).pathname
const json = (b) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) })
const url = new URL(route.request().url())
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/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/activity') return json({ tickets: [] })
if (p === '/api/funktionen') return json({ projects: [SCENARIOS[scenario]] })
if (p === '/api/funktionen/analyse/stats') return json(stats)
if (p === '/api/funktionen') return json({ projects: sc.projects || [sc.project] })
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 })
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
statsRequests = []
await page.goto('about:blank')
await page.goto(`${base}/dashboard.html#analyse`)
await page.waitForSelector('#analyse-body .kb-panel')
await page.waitForTimeout(900)
await page.waitForSelector('#analyse-body .an')
await page.waitForSelector(waitFor)
await page.waitForTimeout(300)
}
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.waitForTimeout(150)
await page.screenshot({ path: `${SHOTS}/task-7-${label}-${name}.png` })
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/${label}-${name}.png` })
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')
const text = await page.textContent('#analyse-body')
check(`${label} verantwortlicher`, text.includes('Bäckerei Berg GmbH') && text.includes('info@baeckerei-berg.example'))
check(`${label} links`, (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'))
/* --- Einschalten (wie bisher) --- */
await open('off', '#an-on')
const body = await text('#analyse-body')
const linkOk = (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`
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')
check(`${label} knopf_frei_mit_haken`, !(await page.isDisabled('#an-on')))
await noScroll('einschalten')
const enabled = !(await page.isDisabled('#an-on'))
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 open('missing')
await open('missing', '#an-missing')
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')
await page.waitForSelector('.an-kpis')
await noScroll('aktiv')
/* --- aktiv, volle Daten --- */
await open('full')
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 Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /Analyse aktiv · Stand: heute, \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)
// 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')
// 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())
await shot('einstellungen')
await page.click('#an-off-ask')
await page.click('#an-off-yes')
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')
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)
check(L('verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht'), (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')
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: 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')
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()
}
} finally {