Plan: Analyse-Übersicht neu (Cockpit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f9dfd8ad5
commit
c1dada3189
1 file changed
+456
@@ -0,0 +1,456 @@
|
||||
# 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:
|
||||
```js
|
||||
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:
|
||||
```js
|
||||
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)
|
||||
|
||||
```js
|
||||
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**
|
||||
|
||||
```bash
|
||||
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“):
|
||||
```js
|
||||
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**
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
```js
|
||||
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.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**
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
```js
|
||||
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.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 „<range> 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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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`).
|
||||
Reference in new issue
Block a user