Files
2026-10-04 21:34:43 +00:00

33 KiB
Raw Permalink Blame History

Analyse-Übersicht neu („Cockpit“) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Die Analyse-Seite im Kundenportal zeigt eine Übersicht im „Cockpit“-Stil: Klartext-Zusammenfassung, Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Kontakt-Aktionen, Datenbasis; Heatmap und Einstellungen in eigenen Reitern.

Architecture: Der Collector (analytics/src/stats.js) liefert zusätzlich Vorzeitraum, firstDay, Tageswerte und Durchschnitte; der Portal-Server reicht das durch. Im Browser: reine Funktionen (analyse-view.js), reine HTML-Bausteine (analyse-cards.js), DOM-Verdrahtung (analyse.js), Stil (analyse.css). Die Analyse-Oberfläche zieht aus funktionen.js aus.

Tech Stack: Node 24 + node:sqlite (Collector), Node 20 + Express (Portal), Vanilla-JS-ES-Module im Browser, node --test, Playwright (playwright-core aus /root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core).

Spec: docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md

Global Constraints

  • Arbeitsort: Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu, Branch feature/analyse-uebersicht. node_modules ist ein Symlink auf ../build/node_modules und bleibt untracked: nie git add -A, immer Dateien einzeln adden.
  • Keine neuen Abhängigkeiten. Keine Änderung an analytics/client/*, public/rechtliches/*, Banner, AVV, Leistungsbeschreibung, Datenschutztexten, WOMS, Schema, .env.
  • Nur vorhandene Daten (Spec U4). Klartext nur durch feste Regeln im Browser (U5).
  • Texte der Zustände Einschalten und Ausschalten bleiben wörtlich wie heute (bestehende Tests in test/funktionenTiles.test.js prüfen sie).
  • Neue Texte: Deutsch, Du-Form klein („du“, „deine“), Zahlen de-DE („6.930“, „7,8“).
  • Element-IDs bleiben: #analyse-body, #an-stage, #an-preview, #an-avv, #an-on, #an-error, #an-color, #an-hint, #an-off, #an-off-*, #an-heat-page.
  • Alle Werte aus Daten (Seitenpfade, Hosts, Projektnamen) werden in HTML maskiert (esc).
  • Akzent nur unter .an: --an-acc: #2dd4bf, --an-acc-strong: #5eead4, --an-acc-soft: rgba(45,212,191,.1), weniger #fda4af, Karte rgba(14,16,20,.92), Rand rgba(255,255,255,.09), Radius 16 px, Abstand 14 px.
  • Reiter-Schlüssel: 'uebersicht' | 'heatmap' | 'einstellungen'. Zeiträume: 7, 30, 90.
  • Portal-Tests laufen auf Node 20 (Produktion) und 24; Collector-Tests auf Node 24.
  • Commit-Nachrichten enden mit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  1. Antwort eines alten Collectors ohne previous, firstDay, from/to und ohne series[].visitors (Deploy-Reihenfolge): Seite rendert ohne Chips und ohne Fehler, Mini-Verläufe nur, wo Werte da sind → Test in Task 4.
  2. Website ohne einen einzigen Aufruf (alles 0, consentRate 0, avgSec null): keine NaN/Infinity/undefined im HTML, Leerzustand im Block „Das Wichtigste“, Ring 0 % → Test in Task 4.
  3. Seitenpfad oder Host mit HTML (/<img src=x onerror=alert(1)>, Host a"b.de): überall maskiert (Hinweise, Ranglisten, Heatmap-Auswahl) → Tests in Task 3 und Task 4.
  4. Schnelles Umschalten von Zeitraum/Website: eine langsame ältere Antwort überschreibt die neuere nicht → Playwright-Check mit verzögerter Antwort in Task 5.
  5. Sehr langer Pfad auf dem Handy (402 px): keine waagrechte Scrollleiste, Text gekürzt → Playwright-Check in Task 5.

Task 1: Collector liefert Vorzeitraum, firstDay, Tageswerte, Durchschnitte; Portal liefert activatedAt

Files:

  • Modify: analytics/src/stats.js (gesamte Datei, 69 Zeilen)
  • Modify: analytics/test/stats.test.js (Test „Stats-Endpunkt: Form …“ ab Zeile 118 anpassen, neue Tests anhängen)
  • Modify: test/analyticsContract.test.js:42-50
  • Modify: server/routes/funktionen.js:51-75 (analyseView)
  • Modify: test/analyseRoutes.test.js:115 (+ neuer Test)

Interfaces:

  • Produces: Antwort von computeStats(db, site, range, now) (und damit GET /a/admin/stats/:key und GET /api/funktionen/analyse/stats) mit den Feldern aus Spec Abschnitt 1: range, from, to, firstDay, totals { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll }, series [{ day, pv, visitors, contacts, avgSec }], pages, refs, countries, devices, contacts, banner, previous { from, to, complete, totals {…gleich…}, series […gleich…], contacts [{key,count}] }.

  • Produces: p.analyse.activatedAt (ISO-String oder null) in GET /api/funktionen.

  • Step 1: Failing Tests im Collector schreiben (analytics/test/stats.test.js, Helfer mk, admin, KEY, NOW = 10.10.2026 12:00 Berlin, D, H existieren)

Bestehenden Test „Stats-Endpunkt: Form …“ anpassen:

assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']);
assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4, engaged: 1, avgSec: 5, avgScroll: 5 });
assert.deepEqual(j.series[5], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 0, avgSec: 5 });
assert.deepEqual(j.series[6], { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1, avgSec: null });
assert.equal(j.from, '2026-10-04'); assert.equal(j.to, '2026-10-10'); assert.equal(j.range, 7);

Neue Tests:

test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => {
  // pv am 30.09. (im Vorzeitraum 27.09.–03.10.), en am 01.10., ct am 02.10., pv am 26.09. (vor dem Vorzeitraum)
  // erst ohne 26.09.: firstDay '2026-09-30', complete false; dann mit 26.09.: firstDay '2026-09-26', complete true
  assert.equal(j.previous.from, '2026-09-27'); assert.equal(j.previous.to, '2026-10-03');
  assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[3].day, '2026-09-30');
  assert.equal(j.previous.totals.pv, 1);       // 26.09. zählt nicht mit
  assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]);
  assert.equal(j.totals.pv, 0);                 // aktueller Zeitraum leer
});
test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { /* '' ohne Ereignisse; nach runNightly weiterhin gleich */ });
test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => {
  // zwei en am selben Tag: sec 30/scroll 50 und sec 91/scroll 100 → avgSec 61 (60,5 gerundet), avgScroll 75, engaged 2
});
test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => {
  const NOV = Date.UTC(2026, 10, 10, 10); // range 30
  // series[0].day '2026-10-12', series[29].day '2026-11-10', previous.series[29].day '2026-10-11', previous.series[0].day '2026-09-12'
  // alle 60 Tage verschieden
});
test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { /* Ereignis im Vorzeitraum vor und nach runNightly: previous.totals.pv bleibt 1 */ });

Die Ereignisse der Tests mit ev({ ts, type, … }) einfügen; runNightly(db, { now: NOW }) verdichtet Tage < heute.

  • Step 2: Tests laufen lassen, sie schlagen fehl

Run: cd analytics && node --test test/stats.test.js Expected: FAIL (fehlende Felder previous, firstDay …)

  • Step 3: computeStats(db, site, range, now = Date.now()) in analytics/src/stats.js umbauen

Aufteilen in eine Sammel-Funktion für eine beliebige Tagesliste und eine Formungs-Funktion:

  • dayList(endDay, range) bildet Kalendertage per Date.UTC(y, m-1, d-i) (wie heute). Aktueller Zeitraum: dayList(dayOf(now), range). Vorzeitraum: dayList(<Tag vor days[0]>, range) – kalendarisch, nie now - range*DAY_MS.

  • collect(db, site, days, now): daily für day IN days und noch nicht in agg_state stehende Tage live aus events_raw (Abfrage begrenzt auf Mitternacht UTC des ersten Tags − 2 Tage bis letzter Tag + 2 Tage und ts <= now, dann nach dayOf(ts) gefiltert; aggregateRows aus jobs.js wie heute). Ergebnis je Tag: pv_total, visitors, ct_kind-Summe, en_count, en_sec_sum, scroll_sum sowie die Top-Dimensionen über alle Tage.

  • Durchschnitte: avgSec = Math.round(en_sec_sum / en_count), avgScroll = Math.round(scroll_sum / en_count), beide null bei en_count = 0; engaged = en_count.

  • firstDay: Minimum aus SELECT MIN(day) FROM daily WHERE site = ? und dayOf(SELECT MIN(ts) FROM events_raw WHERE site = ?), '' wenn beides fehlt. previous.complete = firstDay !== '' && firstDay <= previous.from.

  • pages, refs, countries, devices, contacts, banner, consentRate bleiben exakt wie bisher (nur für den aktuellen Zeitraum); previous.contacts = top(ct_kind) des Vorzeitraums.

  • Step 4: Collector-Tests grün

Run: cd analytics && npm test Expected: PASS (bisher 137 Tests + neue), 0 fail

  • Step 5: Vertragstest ergänzen (test/analyticsContract.test.js nach Zeile 50)
assert.equal(stats.range, 7)
assert.equal(stats.previous.series.length, 7)
assert.ok('firstDay' in stats && 'avgSec' in stats.totals)
assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d))

Run: node --test test/analyticsContract.test.js → PASS

  • Step 6: Failing Test für activatedAt (test/analyseRoutes.test.js)

Zeile 115: erwartetes Objekt um activatedAt: null ergänzen. Im Test nach erfolgreichem activate (Zeile ~128–135): assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/). Run: node --test test/analyseRoutes.test.js → FAIL

  • Step 7: analyseView in server/routes/funktionen.js gibt activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null zurück

Run: node --test test/analyseRoutes.test.js → PASS

  • Step 8: Gesamte Portal-Suite

Run: npm test Expected: PASS, 0 fail (Ausgangslage 2259 pass)

  • Step 9: Commit
git add analytics/src/stats.js analytics/test/stats.test.js test/analyticsContract.test.js server/routes/funktionen.js test/analyseRoutes.test.js
git commit -m "Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht"

Task 2: Analyse-Oberfläche zieht aus funktionen.js aus (ohne sichtbare Änderung)

Files:

  • Create: public/fn-util.js
  • Create: public/analyse.js
  • Modify: public/funktionen.js (Zeilen 6–7 Importe, 28–61 Helfer, 101–110 Analyse-Zustand, 611–980 Analyse-Abschnitt)
  • Modify: public/analyse-view.js (Zustand und Auswahl-Helfer dazu)
  • Modify: public/app.js:1920
  • Modify: test/funktionenTiles.test.js:8-16, test/analyseView.test.js:50-59

Interfaces:

  • Produces: public/fn-util.js exportiert esc(value): string, safeUrl(url): string, request(path, { method, body, raw }): Promise<object> (unverändert aus funktionen.js verschoben).

  • Produces: public/analyse-view.js exportiert zusätzlich analyseOf(project), analyseState(a), pickAnalyseProject(projects) (verschoben) und export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' } (gemeinsamer Zustand für Kachel und Analyse; ersetzt das bisherige analyseSel in funktionen.js).

  • Produces: public/analyse.js exportiert initAnalyse(root, { projectId }), renderAnalyseActivation(stage, project, reload), renderAnalyseOff(box, projectId, reload) (Verhalten unverändert).

  • Importstil: funktionen.js importiert absolut ('/fn-util.js', '/analyse-view.js'), analyse.js und analyse-view.js relativ ('./fn-util.js', './analyse-view.js', './geraete.js') – so lassen sie sich in Node direkt importieren und sind im Browser dieselben Module.

  • Step 1: Tests auf die neue Aufteilung umstellen (schlagen fehl)

test/funktionenTiles.test.js: zusätzlich .replace("'/fn-util.js'", '${fileUrl('fn-util.js')}'); initAnalyse, renderAnalyseOff aus await import(fileUrl('analyse.js')) statt aus funktionen.mjs. test/analyseView.test.js („verdrahtet“):

assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/)
const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8')
assert.doesNotMatch(an, /\bconfirm\(/)
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/)

Run: node --test test/funktionenTiles.test.js test/analyseView.test.js → FAIL

  • Step 2: Verschieben

Abschnitt /* --- Analyse --- */ (Zeile 611 bis Dateiende) und die Analyse-Importe aus funktionen.js nach public/analyse.js; esc/safeUrl/request nach fn-util.js; analyseOf/analyseState/pickAnalyseProject und analyseSel nach analyse-view.js. funktionen.js behält Kacheln und News und setzt beim Klick auf die Analyse-Kachel analyseSel.projectId (importiert). Vorher prüfen, wer die Funktionen sonst nutzt: grep -a -rn "initAnalyse\|renderAnalyse\|analyseSel" public server test.

  • Step 3: app.js:1920 lädt /analyse.js

analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),

  • Step 4: Tests grün, Suite grün

Run: npm test → PASS, 0 fail

  • Step 5: Sichtprüfung, dass nichts anders aussieht

Run: node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task2 Expected: alle Zeilen OK, keine FAIL.

  • Step 6: Commit
git add public/fn-util.js public/analyse.js public/funktionen.js public/analyse-view.js public/app.js test/funktionenTiles.test.js test/analyseView.test.js
git commit -m "Analyse: Oberfläche in eigenes Modul analyse.js, gemeinsame Helfer in fn-util.js"

Task 3: Reine Funktionen für Vergleich und Klartext

Files:

  • Modify: public/analyse-view.js
  • Create: test/analyseInsights.test.js

Interfaces:

  • Consumes: Statistik-Form aus Task 1; esc aus ./fn-util.js.

  • Produces (alle in public/analyse-view.js, ohne DOM):

    • fmtNum(n): string – de-DE, ganzzahlig.
    • fmtDuration(sec): string – '1:48', '0:07'; null/ungültig → '–'.
    • dayLabel(day): string – '2026-09-19' → 'Sa., 19.09.'.
    • periodLabel(from, to): string – '5. Sep. – 4. Okt.' (Monatskürzel Jan., Feb., März, Apr., Mai, Juni, Juli, Aug., Sep., Okt., Nov., Dez.); leere Eingabe → ''.
    • compareFrom(stats): { ok: boolean, from: string } – ok wenn stats.previous?.complete && stats.previous.totals.pv >= 20; from = 'TT.MM.' von firstDay + (2 × range − 1) Tagen nur wenn previous vorhanden, !complete und firstDay; sonst ''.
    • delta(cur, prev, { kind = 'count' } = {}): { dir: 'up'|'down'|'flat', text: string, aria: string } | null – Regeln Spec 2.4; null, wenn cur oder prev keine Zahl ist.
    • contactRate(contacts, visitors): number | null – Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; null wenn visitors < 30.
    • refName(host): string – Spec 2.1 (Google, Bing, Instagram, Facebook, DuckDuckGo, Ecosia, sonst Host; '' → 'Direkt oder unbekannt').
    • weekdayStats(series, firstDay): { best: { weekday, name, avg } | null, avg: number, peak: { day, pv } | null } | null – über Tage ≥ firstDay (alle, wenn ''); null bei < 7 solchen Tagen; best nur wenn Schnitt ≥ 1,2 × Gesamtschnitt; peak nur wenn ≥ 1,8 × Gesamtschnitt.
    • summarize(stats): { lead: string, items: [{ icon: 'contact'|'day'|'source'|'device'|'page', html: string }] } | null – Spec 2.3; null bei totals.pv < 30; lead und html sind fertiges, maskiertes HTML mit <b>.
    • chartModel(series, opts) bekommt die Option compare (Array { pv }): gemeinsame Skala über beide Reihen, zusätzliches Feld prevLine (Pfad, '' ohne compare), points[i].prev (Zahl oder null).
  • Step 1: Failing Tests schreiben (test/analyseInsights.test.js, Testdaten-Helfer stats(over) baut eine vollständige Antwort mit 30 Tagen)

test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => {
  assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' })
  assert.equal(delta(188, 143).text, '▲ 31 %')       // beide ≥ 100 → Prozent
  assert.equal(delta(45, 33).text, '▲ 12')
  assert.equal(delta(30, 33).text, '▼ 3')
  assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' })
  assert.equal(delta(1004, 1000).text, '± 0 %')       // |0,4 %| < 1
  assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s')
  assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05')
  assert.equal(delta(null, 3), null)
})
test('fmtDuration, dayLabel, periodLabel, fmtNum', () => {
  assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–')
  assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.')
  assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.')
  assert.equal(fmtNum(6930), '6.930')
})
test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => {
  assert.deepEqual(compareFrom(stats()), { ok: true, from: '' })
  assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '18.11.' }) // range 30: +59 Tage
  assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' })
  assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false)
})
test('contactRate und refName', () => {
  assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null)
  assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example')
})
test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { /* Samstage 2× Schnitt → best.name 'Samstag'; ein Tag 3× Schnitt → peak */ })
test('summarize: Wachstum mit Prozent', () => {
  const s = summarize(stats())
  assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – <b>14 % mehr</b> als in den 30 Tagen davor.')
  assert.equal(s.items[0].icon, 'contact')
  assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.')
  assert.ok(s.items.length <= 4)
})
test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { /* '– <b>9 % weniger</b> als', '– etwa so oft wie in den 30 Tagen davor.', '– <b>12 Aufrufe mehr</b> als' */ })
test('summarize: ohne Vergleich und neu gestartet', () => {
  // previous fehlt: '…in den letzten 30 Tagen <b>X-mal</b> aufgerufen.'
  // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>X-mal</b> aufgerufen.'
})
test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => {
  assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null)
  // contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.'
})
test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => {
  // refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).'
  // devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.'
  // pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).'
  // alle 5 Bedingungen wahr → Seite fällt weg
})
test('summarize: Pfade und Hosts werden maskiert', () => {
  const s = summarize(stats({ pages: [{ key: '/<img src=x>', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] }))
  assert.ok(!JSON.stringify(s).includes('<img'))
  assert.ok(JSON.stringify(s).includes('&lt;img'))
})
test('chartModel mit compare: gemeinsame Skala, prevLine', () => { /* top über beide Reihen; ohne compare prevLine '' und bestehender Test unverändert */ })
  • Step 2: Tests laufen, schlagen fehl

Run: node --test test/analyseInsights.test.js → FAIL (Exporte fehlen)

  • Step 3: Funktionen in public/analyse-view.js implementieren (Signaturen oben; esc aus './fn-util.js'; Wochentagsnamen Sonntag…Samstag; Spitzentag-Satz „Spitzentag war Sa., 19.09. mit 371 Aufrufen.“ hängt am Wochentag-Hinweis, wenn beides zutrifft)

  • Step 4: Tests grün, Suite grün

Run: node --test test/analyseInsights.test.js test/analyseView.test.js && npm test → PASS

  • Step 5: Commit
git add public/analyse-view.js test/analyseInsights.test.js
git commit -m "Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen"

Task 4: HTML-Bausteine der Cockpit-Ansicht

Files:

  • Create: public/analyse-cards.js
  • Create: test/analyseCards.test.js

Interfaces:

  • Consumes: Task 3 (summarize, delta, compareFrom, contactRate, refName, fmtDuration, fmtNum, periodLabel, dayLabel, chartModel, barRows, countryName, flagEmoji, DEVICE_LABELS), esc.

  • Produces (alle geben HTML-Strings zurück, ohne DOM; relative Importe):

    • headerHtml({ project, usable, range, showRange, devMode, stamp }) – Kopfleiste .an-top; Auswahl <select id="an-project"> nur bei usable.length > 1; Zeitraum-Knöpfe .an-seg button[data-range] nur bei showRange; Status „Analyse aktiv · Stand: heute, 19:42 Uhr“ (stamp = '19:42') bzw. gelb „Im Dev-Modus wird nicht gemessen.“
    • tabsHtml(tab) – role="tablist", Knöpfe [data-tab="uebersicht|heatmap|einstellungen"], aria-selected.
    • overviewHtml(stats) – setzt zusammen: summaryHtml, kpisHtml, Zeile .an-row2 (chartHtml + contactsHtml), rankCardHtml('seen', …) + rankCardHtml('who', …), basisHtml + heatTeaserHtml, Fußzeile „Länderdaten: IP-Geolocation by DB-IP“ (Link wie heute).
    • summaryHtml(stats), kpisHtml(stats), sparkHtml(values), chartHtml(stats), contactsHtml(stats), rankCardHtml(card, stats, { tab, expanded } = {}) mit card 'seen' (Reiter pages | refs) oder 'who' (devices | countries), basisHtml(stats), heatTeaserHtml() (Knopf [data-tab="heatmap"]), skeletonHtml(), errorHtml(message) (Knopf #an-retry), settingsHtml(project) (Status, Unterlagen, leere <section class="an-card" id="an-off">).
    • Texte exakt aus Spec 2.1–2.4 (Leerzustand „Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.“, Quote „Auf 100 Besucher kommen 7,8 Kontakt-Aktionen (davor 6,5).“, „Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.“, Datenbasis-Sätze, „Noch hat niemand über das Banner entschieden.“, „Vergleich ab TT.MM.“).
  • Step 1: Failing Tests schreiben (test/analyseCards.test.js; Datenhelfer wie in Task 3)

test('overviewHtml: volle Daten – Kacheln mit Chips, Vorzeitraum-Linie, Kontakte, Quote', () => {
  const h = overviewHtml(full)
  assert.match(h, /▲ 14 %/); assert.match(h, /vs\. 6\.080/); assert.match(h, /class="an-prev"/)
  assert.match(h, /Ø Zeit pro Seite/); assert.match(h, /1:48/); assert.match(h, /Ø 64 % gescrollt/)
  assert.match(h, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\./)
  assert.match(h, /61 % haben dem Banner zugestimmt \(2\.410 von 3\.950 Entscheidungen\)/)
})
test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => {
  const h = overviewHtml({ totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } })
  assert.doesNotMatch(h, /▲|▼|undefined|NaN/); assert.doesNotMatch(h, /class="an-prev"/)
})
test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => {
  const h = overviewHtml(zero)
  assert.match(h, /Sobald ein paar mehr Besuche da sind/); assert.match(h, /Noch hat niemand über das Banner entschieden\./)
  assert.doesNotMatch(h, /NaN|Infinity|undefined|null/)
})
test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => {})
test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => {})
test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => {
  const h = rankCardHtml('seen', stats({ pages: [{ key: '/<img src=x>', count: 9 }] }))
  assert.match(h, /&lt;img src=x&gt;/); assert.doesNotMatch(h, /<img src=x>/)
})
test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => {})
test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {})
test('errorHtml und skeletonHtml', () => { assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/) })
  • Step 2: Tests laufen, schlagen fehl

Run: node --test test/analyseCards.test.js → FAIL

  • Step 3: public/analyse-cards.js implementieren

Klassen-Präfix an- (Karten .an-card, Kacheln .an-kpi, Chips .an-delta.up|down|flat mit aria-label, Mini-Verlauf .an-spark, Diagramm .an-chart mit .an-area, .an-line, gestrichelt .an-prev (nur wenn compareFrom(stats).ok), Legende „ Tage“ / „Zeitraum davor“ (zweiter Eintrag nur mit Vergleich), Punkte mit data-i, Daten für die Blase als data-day/data-pv/data-prev am Punkt). Mini-Verläufe: Seitenaufrufe series[].pv, Besucher series[].visitors, Kontakte series[].contacts, Zeit series[].avgSec (fehlende Werte als 0, ganz fehlende Reihe → kein Verlauf). Symbole als Inline-SVG (gleicher Stil wie ICONS in funktionen.js, stroke-basiert), keine Emoji.

  • Step 4: Tests grün, Suite grün

Run: node --test test/analyseCards.test.js && npm test → PASS

  • Step 5: Commit
git add public/analyse-cards.js test/analyseCards.test.js
git commit -m "Analyse: HTML-Bausteine der Cockpit-Übersicht"

Task 5: Cockpit verdrahten – Rahmen, Reiter, Übersicht, Heatmap, Einstellungen, Stil

Files:

  • Modify: public/analyse.js
  • Create: public/analyse.css
  • Modify: public/dashboard.html (Zeile 24: <link rel="stylesheet" href="/analyse.css"> nach funktionen.css)
  • Modify: public/funktionen.css:140-218 (Analyse-Abschnitt entfernen; was die Einschalten-Ansicht und Heatmap brauchen, zieht nach analyse.css)
  • Modify: test/ui/analyse.ui.mjs

Interfaces:

  • Consumes: Task 2 (analyseSel, request, esc, safeUrl, bestehende Heatmap-Funktionen in analyse.js), Task 3, Task 4 (alle Bausteine).
  • Produces: initAnalyse(root, { projectId }) rendert <div class="an"> mit headerHtml + (nur Zustand active) tabsHtml + <div id="an-stage">. Bestehende Exporte bleiben.

Verhalten:

  • Zustand active: Reiter aus analyseSel.tab. Übersicht: erst skeletonHtml(), dann overviewHtml(stats); Fehler → errorHtml(…), #an-retry lädt neu; Zähler statsRequestId verwirft veraltete Antworten (wie heute). Zeitraum-Knöpfe setzen analyseSel.range und laden nur die Übersicht neu. #an-project wechselt die Website (Reiter bleibt). Ranglisten-Umschalter und „Alle anzeigen“ rendern nur ihre Karte neu (rankCardHtml). [data-tab="heatmap"] im Teaser wechselt den Reiter.

  • Diagramm-Blase: Zeigen/Tippen auf .an-chart wählt den nächsten Punkt (data-i) und zeigt .an-tip mit „Sa., 19.09.: 371 Aufrufe (davor 210)“ (ohne Vorwert ohne Klammer); verlassen → ausblenden.

  • Heatmap-Reiter: renderAnalyseHeat wie heute; die Seitenliste kommt aus der zuletzt geladenen Statistik (sonst einmal range=30 laden). iframes entstehen erst beim Öffnen des Reiters.

  • Einstellungen-Reiter: settingsHtml(project), danach renderAnalyseOff(stage.querySelector('#an-off'), projectId, reload).

  • Andere Zustände (Einschalten, gesperrt, nicht verfügbar, unbekannt, keine nutzbare Website): Kopfleiste ohne Zeitraum und ohne Reiter, Inhalt im Cockpit-Rahmen, Texte unverändert. root.querySelector kann in Node-Tests für andere Selektoren als #an-stage null liefern – überall ?. verwenden.

  • analyse.css: Werte aus Global Constraints und Spec 2.5; Raster Desktop wie Entwurf A (/root/reports/analyse-ueberarbeitung-2026-10-04/entwurf-a-cockpit.png); .an-row2 mit align-items: stretch, Diagramm füllt die Höhe; ≤ 880 px: Kacheln 2 × 2, alles einspaltig, Reiter overflow-x: auto, Zeitraum volle Breite, lange Namen mit text-overflow: ellipsis; @media (prefers-reduced-motion: reduce) ohne Übergänge.

  • Step 1: UI-Test erweitern (schlägt fehl) – test/ui/analyse.ui.mjs

Neue Szenarien in SCENARIOS/Statistik: full (30 Tage, Werte wie Fixture „Boulderhalle Überhang“: pv 6.930/Vorzeitraum 6.080, visitors 2.410/2.210, contacts 188/143, avgSec 108/114, avgScroll 64, consentRate 0,61, banner 3.950/2.410/1.540, firstDay vor dem Vorzeitraum), neu (firstDay im Zeitraum, previous.complete false), wenig (pv 12), dev (devMode: true), alt (ohne previous), langerPfad (Seite / + 120 Zeichen), fehler (Statistik 503). Prüfungen je Viewport 1440 × 1000 und 402 × 874:

OK  aktiv: Block "Das Wichtigste" mit "14 % mehr"
OK  aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼
OK  Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes
OK  Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"
OK  Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht
OK  verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht
OK  neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse"
OK  wenig: Leerzustand
OK  dev: "Im Dev-Modus wird nicht gemessen."
OK  alt: keine Chips, keine pageerror
OK  fehler: "Auswertung gerade nicht erreichbar" + Neu laden
OK  langer Pfad, 402 px: document.scrollingElement.scrollWidth <= innerWidth
OK  Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher)
OK  keine pageerror in allen Zuständen

Screenshots je Zustand und Viewport nach dem Ordner aus argv[2]. Run: node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5 → FAIL

  • Step 2: analyse.js verdrahten, analyse.css schreiben, dashboard.html und funktionen.css anpassen

  • Step 3: UI-Test grün

Run: node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5 Expected: nur OK-Zeilen.

  • Step 4: Screenshots ansehen und mit Entwurf A vergleichen

Desktop- und Handy-Bilder von full, neu, Einschalten und Einstellungen öffnen (Read-Tool). Erwartet: Aufbau wie Entwurf A, Diagramm und Kontaktkarte gleich hoch, keine abgeschnittenen Texte, keine Überlappungen, Kacheln auf dem Handy 2 × 2. Abweichungen beheben und Step 3 wiederholen.

  • Step 5: Suite grün

Run: npm test → PASS, 0 fail

  • Step 6: Commit
git add public/analyse.js public/analyse.css public/dashboard.html public/funktionen.css test/ui/analyse.ui.mjs
git commit -m "Analyse: Cockpit-Übersicht mit Reitern Übersicht, Heatmap, Einstellungen"

Task 6: Abschlussprüfung auf Node 20 und Bilder für den Nutzer

Files:

  • keine Code-Dateien (nur Prüfung); Bilder nach /root/reports/analyse-ueberarbeitung-2026-10-04/screens/

  • Step 1: Portal-Suite auf Node 20

Run: docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu node:20-alpine node --test test/*.test.js 2>&1 | tail -8 Expected: fail 0 (bekannte Ausnahme laut Notizen: nur sharp-abhängige Tests, falls sharp für musl fehlt – dann diese benennen, nicht ignorieren).

  • Step 2: Collector-Suite auf Node 24

Run: cd analytics && npm test → fail 0

  • Step 3: Bilder für den Nutzer

Run: node test/ui/analyse.ui.mjs /root/reports/analyse-ueberarbeitung-2026-10-04/screens Expected: nur OK; Bilder full, neu, Heatmap, Einstellungen, Einschalten je Desktop/Handy liegen im Ordner.

  • Step 4: Branch-Zustand festhalten

Run: git status --short (nur ?? node_modules) und git log --oneline origin/main..HEAD (Spec, Plan, Tasks 1–5).


Livegang (nur mit OK des Nutzers, nicht Teil der Tasks)

  • L1 Nutzer pusht: git -C /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu push origin feature/analyse-uebersicht:main (vorher ggf. auf origin/main rebasen und npm test erneut).
  • L2 Prüfen: build/ auf dem neuen Stand; docker ps zeigt webklar-kundenbereich-server und webklar-analytics frisch gestartet; curl -s https://project.webklar.com/a/health; im Container Stats einer echten aktiven Website abrufen (über analyticsClient.getStats(siteKey, 30) in docker exec -w /app webklar-kundenbereich-server node -e …) → Felder previous, firstDay, totals.avgSec vorhanden; https://project.webklar.com/analyse.js und /analyse.css 200.
  • L3 Nutzer öffnet Funktionen → Analyse einer Website mit aktiver Analyse und testet Reiter, Zeitraum, Handy.
  • L4 Notizen aktualisieren (Memory analyse-uebersicht-neu.md).