diff --git a/analytics/test/client.test.js b/analytics/test/client.test.js index 912b566..e438971 100644 --- a/analytics/test/client.test.js +++ b/analytics/test/client.test.js @@ -269,7 +269,7 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)' assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/); assert.match(css, /button\{[^}]*[; ]width:150px/); assert.equal((css.match(/(? 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,") 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; } +} diff --git a/public/analyse.js b/public/analyse.js index b18d7ca..e65dfd1 100644 --- a/public/analyse.js +++ b/public/analyse.js @@ -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 = `

${esc(err.message)}

` } 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 = '
Die Analyse ist für deine Websites noch nicht freigeschaltet.
' + root.innerHTML = '

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

' return } const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) analyseSel.projectId = project.projectId - root.innerHTML = ` - ${usable.length > 1 ? ` -
- - -
` : ''} -
` - root.querySelector('#an-project')?.addEventListener('change', (ev) => { - analyseSel.projectId = ev.target.value - initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

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

${esc(err.message)}

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

Analyse ist abgeschaltet

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

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

Die Daten konnten gerade nicht geladen werden.

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

Analyse nicht verfügbar

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

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

Analyse ist abgeschaltet

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

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

Die Daten konnten gerade nicht geladen werden.

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

Analyse nicht verfügbar

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

` else renderAnalyseActivation(stage, project, reload) } +// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens +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 = ` -
+

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

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

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

@@ -112,76 +122,194 @@ export function renderAnalyseActivation(stage, project, reload) { }) } -function lineChart(series) { - const m = chartModel(series) - if (!m.points.length) return '

Noch keine Daten.

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

${esc(title)}

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

Noch keine Daten.

'} -
` -} - -const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${esc(countryName(key))}` - +/* --- 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 = ` -
${[7, 30, 90].map((r) => ``).join('')}
-

Lade…

-
-
-

Länderdaten: IP-Geolocation by DB-IP

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

Seitenaufrufe pro Tag

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

${esc(KPI_NOTE)}

` - renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) + 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 = '
' + const box = stage.querySelector('#an-heat') + if (!state.stats) { + box.innerHTML = '

Heatmap

Lade…

' + 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 = '

Heatmap

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

' + box.innerHTML = '

Heatmap

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

' return } if (!heatListening) { @@ -295,13 +423,11 @@ function renderAnalyseHeat(box, projectId, pages) { } const big = heatSel.big === 't' ? 't' : 'd' box.innerHTML = ` -
-

Heatmap

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

Heatmap

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

-
+
+
${['d', 't'].map((vp) => ``).join('')}
@@ -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) {

Analyse ausschalten

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

-
` +
` const actions = box.querySelector('#an-off-actions') const error = box.querySelector('#an-off-error') box.querySelector('#an-off-ask').addEventListener('click', () => { diff --git a/public/dashboard.html b/public/dashboard.html index 6b48c97..e697b2e 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -22,6 +22,7 @@ + @@ -177,7 +178,7 @@

Lade…

- +

Analyse

diff --git a/public/funktionen.css b/public/funktionen.css index 0cbfd4b..d3078ec 100644 --- a/public/funktionen.css +++ b/public/funktionen.css @@ -137,82 +137,3 @@ .fn-tiles { grid-template-columns: minmax(0, 1fr); } .fn-facts dt { min-width: 0; flex: 1 0 100%; } } - -/* ------------------------------------------------------------- Analyse --- */ -#view-analyse .kb-panel { max-width: none; } -#view-analyse .an-panel { max-width: 720px; } -#view-analyse .fn-project { max-width: 640px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; } -#view-analyse .kb-panel + .kb-panel { margin-top: 16px; } -.an-panel p { margin: 0 0 12px; line-height: 1.55; } -.an-h4 { margin: 18px 0 8px; font-size: 14px; } -.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } -.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: transparent; cursor: pointer; } -.an-presets { display: flex; flex-wrap: wrap; gap: 8px; } -.an-preset { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--kb-border-strong); cursor: pointer; padding: 0; } -.an-hint { margin: 10px 0 0; font-size: 13px; color: #fcd34d; } -/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */ -.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } -.an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; } -.an-bn-link { text-decoration: underline; } -.an-bn-row { display: flex; gap: 10px; flex: none; } -.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 150px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } - -.an-range { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } -.an-tab { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; } -.an-tab.active { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); } -.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } -.an-kpi { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); min-width: 0; } -.an-kpi span { display: block; font-size: 13px; color: var(--kb-muted); } -.an-kpi b { display: block; margin-top: 4px; font-size: 26px; font-variant-numeric: tabular-nums; } -.an-chart-box { margin-bottom: 16px; } -.an-chart { display: block; width: 100%; height: auto; } -.an-grid { stroke: var(--kb-border-strong); stroke-width: 1; } -.an-axis { fill: var(--kb-muted); font-size: 11px; } -.an-line { fill: none; stroke: var(--kb-lilac); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; } -.an-area { fill: var(--kb-purple-soft); stroke: none; } -.an-dot { fill: var(--kb-lilac); } -.an-grid-lists { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; align-items: start; } -#view-analyse .an-grid-lists .kb-panel { margin-top: 0; } -.an-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } -.an-row-top { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; } -.an-row-label { min-width: 0; overflow-wrap: anywhere; } -.an-row-top b { font-variant-numeric: tabular-nums; } -.an-bar { height: 6px; margin-top: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.07); overflow: hidden; } -.an-bar i { display: block; height: 100%; border-radius: 3px; background: var(--kb-purple); } -.an-note, .an-credit { margin: 16px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--kb-muted); } -.an-credit { font-size: 11.5px; color: var(--kb-faint); } -.an-credit a { color: inherit; } -.an-heat { margin-top: 16px; } -.an-heat p, .an-off p { margin: 0 0 12px; line-height: 1.55; } -.an-heat-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; } -.an-heat-row label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; flex: 1 1 200px; min-width: 0; } -.an-heat-row select { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; min-width: 0; } -.an-heat-row select option { color: #111; } -/* Heatmap-Buehne: grosses Geraet (Computer oder Tablet) und Handy nebeneinander, unten buendig (Rahmen: geraete.css) */ -.an-heat .hs-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; } -.an-heat .hs-head h3 { margin: 0; } -.hs-switch { display: flex; gap: 8px; } -.hs-stage { display: grid; grid-template-columns: minmax(0, 3.7fr) minmax(0, 1fr); align-items: end; gap: clamp(16px, 3vw, 40px); max-width: 980px; margin: 24px auto 4px; } -.hs-stage[data-big="t"] { grid-template-columns: minmax(0, 2.1fr) minmax(0, 1fr); max-width: 620px; } -.hs-dev { margin: 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; } -.hs-frame { width: 100%; } -.hs-cap { min-height: 1.5em; font-size: 13px; line-height: 1.5; color: var(--kb-muted); text-align: center; } -.hs-dev .dm-canvas { background: #fff; } -.hs-site { position: absolute; left: 0; background: #fff; } -.hs-site iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; } -@media (max-width: 640px) { - .hs-stage, .hs-stage[data-big="t"] { grid-template-columns: minmax(0, 1fr); gap: 28px; } - .hs-dev--phone .hs-frame { width: 62%; } -} -.an-off { margin-top: 16px; } -.an-ghost { padding: 9px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; } -.an-ghost:hover { background: rgba(255, 255, 255, 0.07); } -.an-danger { color: #fca5a5; } - -@media (max-width: 880px) { - .an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .an-grid-lists { grid-template-columns: minmax(0, 1fr); } - .an-bn { display: block; margin: 12px 0; padding: 16px; } - .an-bn-row { flex-direction: column; margin-top: 12px; } - .an-bn-btn { width: 100%; } -} diff --git a/test/ui/analyse.ui.mjs b/test/ui/analyse.ui.mjs index 88310ca..6044d7b 100644 --- a/test/ui/analyse.ui.mjs +++ b/test/ui/analyse.ui.mjs @@ -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 {