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:
1 parent
1964e5fcca
commit
a9f0711e97
6 files changed
+763
-222
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user