33 KiB
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, Branchfeature/analyse-uebersicht.node_modulesist ein Symlink auf../build/node_modulesund bleibt untracked: niegit 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.jsprü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, Kartergba(14,16,20,.92), Randrgba(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
- Antwort eines alten Collectors ohne
previous,firstDay,from/tound ohneseries[].visitors(Deploy-Reihenfolge): Seite rendert ohne Chips und ohne Fehler, Mini-Verläufe nur, wo Werte da sind → Test in Task 4. - Website ohne einen einzigen Aufruf (alles 0,
consentRate0,avgSecnull): keineNaN/Infinity/undefinedim HTML, Leerzustand im Block „Das Wichtigste“, Ring 0 % → Test in Task 4. - Seitenpfad oder Host mit HTML (
/<img src=x onerror=alert(1)>, Hosta"b.de): überall maskiert (Hinweise, Ranglisten, Heatmap-Auswahl) → Tests in Task 3 und Task 4. - Schnelles Umschalten von Zeitraum/Website: eine langsame ältere Antwort überschreibt die neuere nicht → Playwright-Check mit verzögerter Antwort in Task 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 damitGET /a/admin/stats/:keyundGET /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 odernull) inGET /api/funktionen. -
Step 1: Failing Tests im Collector schreiben (
analytics/test/stats.test.js, Helfermk,admin,KEY,NOW= 10.10.2026 12:00 Berlin,D,Hexistieren)
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())inanalytics/src/stats.jsumbauen
Aufteilen in eine Sammel-Funktion für eine beliebige Tagesliste und eine Formungs-Funktion:
-
dayList(endDay, range)bildet Kalendertage perDate.UTC(y, m-1, d-i)(wie heute). Aktueller Zeitraum:dayList(dayOf(now), range). Vorzeitraum:dayList(<Tag vor days[0]>, range)– kalendarisch, nienow - range*DAY_MS. -
collect(db, site, days, now):dailyfürday IN daysund noch nicht inagg_statestehende Tage live ausevents_raw(Abfrage begrenzt auf Mitternacht UTC des ersten Tags − 2 Tage bis letzter Tag + 2 Tage undts <= now, dann nachdayOf(ts)gefiltert;aggregateRowsausjobs.jswie heute). Ergebnis je Tag:pv_total,visitors,ct_kind-Summe,en_count,en_sec_sum,scroll_sumsowie die Top-Dimensionen über alle Tage. -
Durchschnitte:
avgSec = Math.round(en_sec_sum / en_count),avgScroll = Math.round(scroll_sum / en_count), beidenullbeien_count = 0;engaged = en_count. -
firstDay: Minimum ausSELECT MIN(day) FROM daily WHERE site = ?unddayOf(SELECT MIN(ts) FROM events_raw WHERE site = ?),''wenn beides fehlt.previous.complete = firstDay !== '' && firstDay <= previous.from. -
pages,refs,countries,devices,contacts,banner,consentRatebleiben 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.jsnach 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:
analyseViewinserver/routes/funktionen.jsgibtactivatedAt: doc?.state === 'active' ? doc.activatedAt || null : nullzurü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.jsexportiertesc(value): string,safeUrl(url): string,request(path, { method, body, raw }): Promise<object>(unverändert ausfunktionen.jsverschoben). -
Produces:
public/analyse-view.jsexportiert zusätzlichanalyseOf(project),analyseState(a),pickAnalyseProject(projects)(verschoben) undexport const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }(gemeinsamer Zustand für Kachel und Analyse; ersetzt das bisherigeanalyseSelinfunktionen.js). -
Produces:
public/analyse.jsexportiertinitAnalyse(root, { projectId }),renderAnalyseActivation(stage, project, reload),renderAnalyseOff(box, projectId, reload)(Verhalten unverändert). -
Importstil:
funktionen.jsimportiert absolut ('/fn-util.js','/analyse-view.js'),analyse.jsundanalyse-view.jsrelativ ('./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:1920lä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;
escaus./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 }–okwennstats.previous?.complete && stats.previous.totals.pv >= 20;from='TT.MM.'vonfirstDay + (2 × range − 1)Tagen nur wennpreviousvorhanden,!completeundfirstDay; sonst''.delta(cur, prev, { kind = 'count' } = {}): { dir: 'up'|'down'|'flat', text: string, aria: string } | null– Regeln Spec 2.4;null, wenncuroderprevkeine Zahl ist.contactRate(contacts, visitors): number | null– Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle;nullwennvisitors < 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'');nullbei < 7 solchen Tagen;bestnur wenn Schnitt ≥ 1,2 × Gesamtschnitt;peaknur wenn ≥ 1,8 × Gesamtschnitt.summarize(stats): { lead: string, items: [{ icon: 'contact'|'day'|'source'|'device'|'page', html: string }] } | null– Spec 2.3;nullbeitotals.pv < 30;leadundhtmlsind fertiges, maskiertes HTML mit<b>.chartModel(series, opts)bekommt die Optioncompare(Array{ pv }): gemeinsame Skala über beide Reihen, zusätzliches FeldprevLine(Pfad,''ohnecompare),points[i].prev(Zahl odernull).
-
Step 1: Failing Tests schreiben (
test/analyseInsights.test.js, Testdaten-Helferstats(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('<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.jsimplementieren (Signaturen oben;escaus'./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 beiusable.length > 1; Zeitraum-Knöpfe.an-seg button[data-range]nur beishowRange; 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 } = {})mitcard'seen'(Reiterpages|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, /<img src=x>/); 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.jsimplementieren
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">nachfunktionen.css) - Modify:
public/funktionen.css:140-218(Analyse-Abschnitt entfernen; was die Einschalten-Ansicht und Heatmap brauchen, zieht nachanalyse.css) - Modify:
test/ui/analyse.ui.mjs
Interfaces:
- Consumes: Task 2 (
analyseSel,request,esc,safeUrl, bestehende Heatmap-Funktionen inanalyse.js), Task 3, Task 4 (alle Bausteine). - Produces:
initAnalyse(root, { projectId })rendert<div class="an">mitheaderHtml+ (nur Zustandactive)tabsHtml+<div id="an-stage">. Bestehende Exporte bleiben.
Verhalten:
-
Zustand
active: Reiter ausanalyseSel.tab. Übersicht: erstskeletonHtml(), dannoverviewHtml(stats); Fehler →errorHtml(…),#an-retrylädt neu; ZählerstatsRequestIdverwirft veraltete Antworten (wie heute). Zeitraum-Knöpfe setzenanalyseSel.rangeund laden nur die Übersicht neu.#an-projectwechselt 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-chartwählt den nächsten Punkt (data-i) und zeigt.an-tipmit „Sa., 19.09.: 371 Aufrufe (davor 210)“ (ohne Vorwert ohne Klammer); verlassen → ausblenden. -
Heatmap-Reiter:
renderAnalyseHeatwie heute; die Seitenliste kommt aus der zuletzt geladenen Statistik (sonst einmalrange=30laden). iframes entstehen erst beim Öffnen des Reiters. -
Einstellungen-Reiter:
settingsHtml(project), danachrenderAnalyseOff(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.querySelectorkann in Node-Tests für andere Selektoren als#an-stagenullliefern – ü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-row2mitalign-items: stretch, Diagramm füllt die Höhe; ≤ 880 px: Kacheln 2 × 2, alles einspaltig, Reiteroverflow-x: auto, Zeitraum volle Breite, lange Namen mittext-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.jsverdrahten,analyse.cssschreiben,dashboard.htmlundfunktionen.cssanpassen -
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. auforigin/mainrebasen undnpm testerneut). - L2 Prüfen:
build/auf dem neuen Stand;docker pszeigtwebklar-kundenbereich-serverundwebklar-analyticsfrisch gestartet;curl -s https://project.webklar.com/a/health; im Container Stats einer echten aktiven Website abrufen (überanalyticsClient.getStats(siteKey, 30)indocker exec -w /app webklar-kundenbereich-server node -e …) → Felderprevious,firstDay,totals.avgSecvorhanden;https://project.webklar.com/analyse.jsund/analyse.css200. - L3 Nutzer öffnet Funktionen → Analyse einer Website mit aktiver Analyse und testet Reiter, Zeitraum, Handy.
- L4 Notizen aktualisieren (Memory
analyse-uebersicht-neu.md).