diff --git a/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md new file mode 100644 index 0000000..707f69b --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md @@ -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 `. + +## 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** (`/`, 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(, 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` (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 ``. + - `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 6.930-mal aufgerufen – 14 % mehr 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', () => { /* '– 9 % weniger als', '– etwa so oft wie in den 30 Tagen davor.', '– 12 Aufrufe mehr als' */ }) +test('summarize: ohne Vergleich und neu gestartet', () => { + // previous fehlt: '…in den letzten 30 Tagen X-mal aufgerufen.' + // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website X-mal 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: '/', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] })) + assert.ok(!JSON.stringify(s).includes(' { /* 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 `