diff --git a/analytics/src/stats.js b/analytics/src/stats.js index 1e72c90..f065080 100644 --- a/analytics/src/stats.js +++ b/analytics/src/stats.js @@ -97,10 +97,15 @@ function firstDayOf(db, site) { return fromDaily < fromRaw ? fromDaily : fromRaw; } +// Der Zeitraum sind die `range` abgeschlossenen Kalendertage, die gestern enden (wie GA4 "Letzte 28 Tage"): Der +// angebrochene heutige Tag würde gegen einen vollen Vorzeitraum als Einbruch erscheinen. Heute steht getrennt in `today`. export function computeStats(db, site, range, now = Date.now()) { - const days = dayList(dayOf(now), range); + const todayDay = dayOf(now); + const days = dayList(shiftDay(todayDay, -1), range); const cur = collect(db, site, days, now); const { banner, totals, series } = shape(cur); + // Heute bis jetzt: live aus den Rohdaten (der Tag ist noch nicht verdichtet) + const t = shape(collect(db, site, [todayDay], now)).series[0]; // Vorzeitraum: die `range` Kalendertage direkt vor dem aktuellen Zeitraum (kalendarisch, nie now - range * 24 h) const prevDays = dayList(shiftDay(days[0], -1), range); const prevCollected = collect(db, site, prevDays, now); @@ -113,6 +118,7 @@ export function computeStats(db, site, range, now = Date.now()) { firstDay, totals, series, + today: { day: todayDay, pv: t.pv, visitors: t.visitors, contacts: t.contacts }, pages: top(cur.dims.pv_path || new Map()), refs: top(cur.dims.pv_ref || new Map()), countries: top(cur.dims.pv_country || new Map()), diff --git a/analytics/test/stats.test.js b/analytics/test/stats.test.js index 3083502..c27d47d 100644 --- a/analytics/test/stats.test.js +++ b/analytics/test/stats.test.js @@ -115,12 +115,13 @@ async function admin(db, now = () => NOW) { return { get, close: () => server.close() }; } -test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus daily, consentRate', async () => { +test('Stats-Endpunkt: abgeschlossene Tage bis gestern, heute getrennt in today, Vergangenheit aus daily, consentRate', async () => { const { db, ev } = mk(); const y = NOW - D; for (let i = 0; i < 3; i++) ev({ ts: y + i, type: 'pv', path: '/a', vp: 'd', ref: 'g.de', country: 'DE', device: 'd' }); ev({ ts: y + 10, type: 'en', path: '/a', vp: 'd', visitor: 'v1', sec: 5, scroll: 5 }); ev({ ts: y + 11, type: 'bn', kind: 'accept' }); + ev({ ts: y + 12, type: 'ct', path: '/a', visitor: 'v1', kind: 'tel' }); runNightly(db, { now: NOW }); // gestern -> daily ev({ ts: NOW - H, type: 'pv', path: '/b', vp: 'm', country: 'AT', device: 'm' }); // heute live ev({ ts: NOW - H, type: 'ct', path: '/b', visitor: 'v9', kind: 'mail' }); @@ -132,60 +133,101 @@ test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus dai const r = await a.get(`/a/admin/stats/${KEY}?range=7`); assert.equal(r.status, 200); const j = await r.json(); - assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']); + assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'today', 'totals']); + // Der Zeitraum sind die 7 abgeschlossenen Tage bis gestern (3.–9.10.); heute (10.10.) steht nur in `today` assert.equal(j.series.length, 7); - assert.equal(j.series[0].day, '2026-10-04'); - assert.equal(j.series[6].day, '2026-10-10'); - assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]); - 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); - assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]); + assert.equal(j.series[0].day, '2026-10-03'); + assert.equal(j.series[6].day, '2026-10-09'); + assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 0, 3]); + assert.deepEqual(j.totals, { pv: 3, visitors: 1, contacts: 1, consentRate: 1, engaged: 1, avgSec: 5, avgScroll: 5 }); + assert.deepEqual(j.series[6], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 1, avgSec: 5 }); + assert.deepEqual(j.today, { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1 }); + assert.equal(j.from, '2026-10-03'); assert.equal(j.to, '2026-10-09'); assert.equal(j.range, 7); + assert.deepEqual(j.pages, [{ key: '/a', count: 3 }]); assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 }); - assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]); - assert.deepEqual(j.devices, [{ key: 'd', count: 3 }, { key: 'm', count: 1 }]); - assert.deepEqual(j.contacts, [{ key: 'mail', count: 1 }]); - assert.deepEqual(j.banner, { shown: 1, accept: 2, reject: 2 }); + assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }]); + assert.deepEqual(j.devices, [{ key: 'd', count: 3 }]); + assert.deepEqual(j.contacts, [{ key: 'tel', count: 1 }]); + assert.deepEqual(j.banner, { shown: 0, accept: 1, reject: 0 }); // range 90: 90 Tage const j90 = await (await a.get(`/a/admin/stats/${KEY}?range=90`)).json(); assert.equal(j90.series.length, 90); - assert.equal(j90.totals.pv, 4); + assert.equal(j90.series[89].day, '2026-10-09'); + assert.equal(j90.totals.pv, 3); + assert.deepEqual(j90.today, j.today); a.close(); }); +test('Stats: today = heutiger Berliner Tag bis jetzt, auch ohne Ereignisse; Mitternacht in Berlin trennt gestern und heute', async () => { + const { db, ev } = mk(); + const a = await admin(db); + assert.deepEqual((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).today, { day: '2026-10-10', pv: 0, visitors: 0, contacts: 0 }); + a.close(); + // 11.10. 00:10 Berlin (= 10.10. 22:10 UTC, Sommerzeit): 23:00 Berlin am 10.10. gehört zu gestern, 00:05 Berlin am 11.10. zu heute + const LATE = Date.UTC(2026, 9, 10, 22, 10); + ev({ ts: Date.UTC(2026, 9, 10, 21, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 10, 22, 5), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 10, 22, 6), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 10, 22, 7), type: 'ct', path: '/', visitor: 'v1', kind: 'wa' }); + ev({ ts: Date.UTC(2026, 9, 10, 23, 0), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // noch in der Zukunft: zählt nicht + const b = await admin(db, () => LATE); + const j = await (await b.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.to, '2026-10-10'); + assert.equal(j.series[6].day, '2026-10-10'); assert.equal(j.series[6].pv, 1); + assert.equal(j.totals.pv, 1); + assert.deepEqual(j.today, { day: '2026-10-11', pv: 2, visitors: 1, contacts: 1 }); + assert.equal(j.contacts.length, 0); // die Kontakt-Aktion von 00:07 ist heute + b.close(); +}); + test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', async () => { const { db, ev } = mk(); ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // heute: nur in today const a = await admin(db); - assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1); + const stats = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()); + assert.equal((await stats()).totals.pv, 1); runNightly(db, { now: NOW }); - assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1); + const j = await stats(); + assert.equal(j.totals.pv, 1); + assert.equal(j.today.pv, 1); a.close(); }); -test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => { +test('Stats: Vorzeitraum = die range Kalendertage vor dem aktuellen Zeitraum, complete nach firstDay', async () => { const { db, ev } = mk(); - // Vorzeitraum zu range 7 am 10.10.: 27.09.–03.10. + // aktueller Zeitraum zu range 7 am 10.10.: 03.–09.10.; Vorzeitraum: 26.09.–02.10. ev({ ts: Date.UTC(2026, 8, 30, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 20, scroll: 40 }); ev({ ts: Date.UTC(2026, 9, 2, 10), type: 'ct', path: '/', visitor: 'v2', kind: 'tel' }); const a = await admin(db); - let j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()); + let j = await get(); assert.equal(j.firstDay, '2026-09-30'); assert.equal(j.previous.complete, false); - // 26.09. liegt vor dem Vorzeitraum und darf nicht mitzählen + // 27.09. liegt noch einen Tag nach dem Beginn des Vorzeitraums (26.09.): weiter unvollständig + ev({ ts: Date.UTC(2026, 8, 27, 10), type: 'pv', path: '/x', vp: 'd', country: 'DE', device: 'd' }); + j = await get(); + assert.equal(j.firstDay, '2026-09-27'); + assert.equal(j.previous.complete, false); + // genau am ersten Tag des Vorzeitraums: vollständig ev({ ts: Date.UTC(2026, 8, 26, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); - j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + j = await get(); assert.equal(j.firstDay, '2026-09-26'); assert.equal(j.previous.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); + // 25.09. liegt vor dem Vorzeitraum und darf nicht mitzählen + ev({ ts: Date.UTC(2026, 8, 25, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + j = await get(); + assert.equal(j.firstDay, '2026-09-25'); + assert.equal(j.previous.complete, true); + assert.equal(j.previous.from, '2026-09-26'); assert.equal(j.previous.to, '2026-10-02'); + assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[4].day, '2026-09-30'); + assert.equal(j.previous.totals.pv, 3); // 26.09., 27.09., 30.09. assert.equal(j.previous.totals.engaged, 1); assert.equal(j.previous.totals.avgSec, 20); assert.equal(j.previous.totals.contacts, 1); - assert.deepEqual(j.previous.series[4], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 }); + assert.deepEqual(j.previous.series[5], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 }); + assert.deepEqual(j.previous.series[6], { day: '2026-10-02', pv: 0, visitors: 1, contacts: 1, avgSec: null }); assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]); assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer assert.equal(j.totals.avgSec, null); @@ -223,8 +265,8 @@ test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async ( assert.equal(j.totals.engaged, 2); assert.equal(j.totals.avgSec, 61); // 60,5 gerundet assert.equal(j.totals.avgScroll, 75); - assert.equal(j.series[5].avgSec, 61); - assert.equal(j.series[4].avgSec, null); + assert.equal(j.series[6].avgSec, 61); // 9.10. ist der letzte abgeschlossene Tag + assert.equal(j.series[5].avgSec, null); // ohne en: beide null, engaged 0 const leer = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); assert.equal(leer.previous.totals.engaged, 0); @@ -238,20 +280,23 @@ test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappu // schon der 12.10. (00:30 Berlin) und ein naives Fenster würde sich überlappen; mittags fiele das nicht auf. const NOV = Date.UTC(2026, 10, 10, 22, 30); const { db, ev } = mk(); - ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: Vorzeitraum - ev({ ts: Date.UTC(2026, 9, 12, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 12.10. mittags: aktueller Zeitraum + ev({ ts: Date.UTC(2026, 9, 10, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.10. mittags: Vorzeitraum + ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: aktueller Zeitraum + ev({ ts: Date.UTC(2026, 10, 10, 12), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 10.11. mittags: heute const a = await admin(db, () => NOV); const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); - assert.equal(j.series[0].day, '2026-10-12'); - assert.equal(j.series[29].day, '2026-11-10'); - assert.equal(j.previous.series[29].day, '2026-10-11'); - assert.equal(j.previous.series[0].day, '2026-09-12'); - assert.equal(j.previous.from, '2026-09-12'); assert.equal(j.previous.to, '2026-10-11'); + assert.equal(j.series[0].day, '2026-10-11'); + assert.equal(j.series[29].day, '2026-11-09'); + assert.equal(j.previous.series[29].day, '2026-10-10'); + assert.equal(j.previous.series[0].day, '2026-09-11'); + assert.equal(j.from, '2026-10-11'); assert.equal(j.to, '2026-11-09'); + assert.equal(j.previous.from, '2026-09-11'); assert.equal(j.previous.to, '2026-10-10'); const all = [...j.series, ...j.previous.series].map((s) => s.day); assert.equal(all.length, 60); assert.equal(new Set(all).size, 60); assert.equal(j.previous.totals.pv, 1); assert.equal(j.previous.series[29].pv, 1); assert.equal(j.totals.pv, 1); assert.equal(j.series[0].pv, 1); + assert.equal(j.today.day, '2026-11-10'); assert.equal(j.today.pv, 1); a.close(); }); @@ -274,7 +319,7 @@ test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => { const { db, ev } = mk(); - for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' }); + for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - D - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' }); const a = await admin(db); const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); assert.equal(j.pages.length, 20); diff --git a/public/analyse-cards.js b/public/analyse-cards.js index 49bfed3..c975c60 100644 --- a/public/analyse-cards.js +++ b/public/analyse-cards.js @@ -2,7 +2,7 @@ Jeder Wert aus Daten (Pfade, Hosts, Projektnamen, Tage, Links) laeuft durch esc(). Texte: Spec 2.1-2.4. Vertrag fuer analyse.js und analyse.css (Praefix an-, Zustaende als Klasse "on" oder Attribut): - Kopf .an-top > .an-site[data-state=on|warn|off] (.an-status-dot, .an-site-name | select#an-project.an-pick, .an-status) + .an-seg[role=group] button[data-range=7|30|90][aria-pressed] (.on) + Kopf .an-top > .an-site[data-state=on|warn|off] (.an-status-dot, .an-site-name | select#an-project.an-pick, .an-status "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr") + .an-seg[role=group] button[data-range=7|30|90][aria-pressed] (.on) Reiter .an-tabs[role=tablist] > button.an-tabbtn[role=tab][data-tab=uebersicht|heatmap|einstellungen][aria-selected] (.on), aria-controls="an-stage" Wurzel .an-ov (Raster, Abstand 14 px) enthaelt: .an-sum, .an-kpis, .an-row2, 2x .an-pair, .an-credit Wichtigste .an-sum > h3.an-eyebrow, p.an-lead (darin ), ul.an-points > li > span.an-point-ico (svg.an-ico) + span; Leerzustand: .an-sum.an-sum-empty @@ -50,19 +50,24 @@ const rateDe = (n) => n.toLocaleString('de-DE', { minimumFractionDigits: 1, maxi const STATE_TEXT = { disabled: 'Analyse ist abgeschaltet', unknown: 'Status gerade nicht abrufbar' } -// Statuszeile: aktiv (mit Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand -function statusOf(project, devMode, stamp) { +// Statuszeile: aktiv (mit "Heute bisher" aus stats.today und Stand), Dev-Modus (gelb) oder, bei Websites ohne Messung, der tatsaechliche Zustand +function statusOf(project, devMode, stamp, today) { if (devMode) return { s: 'warn', text: 'Im Dev-Modus wird nicht gemessen.' } const a = project?.analyse const state = typeof a?.state === 'string' ? a.state : 'active' - if (state === 'active') return { s: 'on', text: `Analyse aktiv${stamp ? ` · Stand: heute, ${stamp} Uhr` : ''}` } + if (state === 'active') { + const pv = isNum(today?.pv) ? today.pv : null // alter Collector ohne today: kein "Heute bisher" + // jeder Teil bricht nur als Ganzes in die naechste Zeile (kein "Stand:" allein am Zeilenende) + const parts = ['Analyse aktiv', pv === null ? '' : `Heute bisher: ${fmtNum(pv)} ${pv === 1 ? 'Aufruf' : 'Aufrufe'}`, stamp ? `Stand: ${stamp} Uhr` : ''] + return { s: 'on', text: parts.filter(Boolean).map((t) => `${t}`).join(' · ') } + } return { s: 'off', text: Object.hasOwn(STATE_TEXT, state) ? STATE_TEXT[state] : a.eligible === false ? 'Analyse nicht verfügbar' : 'Analyse ist noch ausgeschaltet' } } -/** Kopfleiste. usable: Websites mit Analyse (Auswahlfeld erst ab 2), stamp: Uhrzeit "19:42" des Ladens. */ -export function headerHtml({ project, usable, range, showRange, devMode, stamp } = {}) { +/** Kopfleiste. usable: Websites mit Analyse (Auswahlfeld erst ab 2), stamp: Uhrzeit "19:42" des Ladens, today: stats.today (heute bisher). */ +export function headerHtml({ project, usable, range, showRange, devMode, stamp, today } = {}) { const sites = list(usable) - const st = statusOf(project, devMode, stamp ? esc(stamp) : '') + const st = statusOf(project, devMode, stamp ? esc(stamp) : '', today) const current = RANGES.includes(Number(range)) ? Number(range) : 30 const name = sites.length > 1 ? `` @@ -92,19 +97,33 @@ export function summaryHtml(stats) { return `
${head}

${s.lead}

${points}
` } -/** Mini-Verlauf (Flaeche + Linie, ohne Achsen) aus Zahlen; unter 2 Werten nichts. */ +/** Mini-Verlauf (Flaeche + Linie, ohne Achsen) aus Zahlen; null/undefined ist eine Luecke (Tag ohne Messung): die Linie bricht dort ab, es wird nie als 0 gezeichnet. Unter 2 Werten nichts. */ export function sparkHtml(values) { - const v = list(values).map(num) - if (v.length < 2) return '' - const max = Math.max(1, ...v) * 1.1 - const path = v.map((y, i) => `${i ? 'L' : 'M'}${round((200 * i) / (v.length - 1))} ${round(36 - (32 * y) / max)}`).join(' ') - return `` + const v = list(values).map((x) => (x === null || x === undefined ? null : num(x))) + if (v.length < 2 || v.filter((x) => x !== null).length < 2) return '' + const max = Math.max(1, ...v.filter((x) => x !== null)) * 1.1 + const x = (i) => round((200 * i) / (v.length - 1)) + const pt = (y, i) => `${x(i)} ${round(36 - (32 * y) / max)}` + // zusammenhaengende Abschnitte ohne Luecke: [[wert, index], ...] + const runs = [] + v.forEach((y, i) => { + if (y === null) runs.push(null) + else if (runs.length && runs[runs.length - 1]) runs[runs.length - 1].push([y, i]) + else runs.push([[y, i]]) + }) + const parts = runs.filter(Boolean) + const seg = (r) => r.map(([y, i], k) => `${k ? 'L' : 'M'}${pt(y, i)}`).join(' ') + // ein einzelner Tag zwischen Luecken wird ein Punkt (Strich der Laenge 0, runde Linienenden) + const line = parts.map((r) => (r.length === 1 ? `${seg(r)} L${pt(...r[0])}` : seg(r))).join(' ') + const area = parts.filter((r) => r.length > 1).map((r) => `${seg(r)} L${x(r[r.length - 1][1])} 38 L${x(r[0][1])} 38 Z`).join(' ') + return `` } -// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null -const trend = (series, key) => { +// Tageswerte einer Reihe: fehlende Werte 0; Reihe ohne das Feld (alter Collector) => null. +// gaps: Tage ohne Wert bleiben null (Luecke im Verlauf), z.B. die Zeit pro Seite an Tagen ohne Zustimmung +const trend = (series, key, gaps = false) => { const rows = list(series) - return rows.some((d) => Object.hasOwn(d ?? {}, key)) ? rows.map((d) => num(d?.[key])) : null + return rows.some((d) => Object.hasOwn(d ?? {}, key)) ? rows.map((d) => (gaps && !isNum(d?.[key]) ? null : num(d?.[key]))) : null } export function kpisHtml(stats) { @@ -120,7 +139,7 @@ export function kpisHtml(stats) { const field = { pv: 'pv', visitors: 'visitors', contacts: 'contacts', time: 'avgSec' } const tile = (k) => { const foot = k.d ? `${chip(k.d)}vs. ${esc(k.vs)}` : !cmp.ok && cmp.from ? `Vergleich ab ${esc(cmp.from)}` : '' - return `
${k.label}${k.star ? '*' : ''}${esc(k.value)}${k.sub ? `${esc(k.sub)}` : ''}
${foot}
${sparkHtml(trend(stats?.series, field[k.key]))}
` + return `
${k.label}${k.star ? '*' : ''}${esc(k.value)}${k.sub ? `${esc(k.sub)}` : ''}
${foot}
${sparkHtml(trend(stats?.series, field[k.key], k.key === 'time'))}
` } return `
${tiles.map(tile).join('')}
` } @@ -139,7 +158,11 @@ export function chartHtml(stats) { const cmp = compareFrom(stats) const head = (legend) => `

Seitenaufrufe pro Tag

${legend}
` if (!series.length) return `
${head('')}

Noch keine Daten.

` - const m = chartModel(series, { height: 300, pad: { l: 40, r: 24, t: 12, b: 28 }, compare: cmp.ok ? stats.previous.series : undefined }) + // Schrift der Achsen in viewBox-Einheiten: bis ca. 24 auf dem schmalsten Handy (analyse.css). Der linke Rand haelt die breiteste + // y-Beschriftung (Ziffern ca. 0,6 x 24 breit), die x-Beschriftungen halten 100 Einheiten Abstand (eine Datumsbeschriftung ist bei Schrift 28 ca. 92 breit) + const opts = { height: 300, maxLabels: 5, labelGap: 100, compare: cmp.ok ? stats.previous.series : undefined } + const padL = Math.max(40, Math.round(6 + String(chartModel(series, opts).top).length * 14.4)) + const m = chartModel(series, { ...opts, pad: { l: padL, r: 24, t: 12, b: 40 } }) const hasPrev = Boolean(m.prevLine) const days = Number(stats?.range) || series.length const total = m.points.reduce((sum, p) => sum + p.pv, 0) @@ -147,8 +170,8 @@ export function chartHtml(stats) { const legend = `
${days} Tage${hasPrev ? 'Zeitraum davor' : ''}
` const dots = m.points.map((p, i) => ``).join('') return `
${head(legend)}
${esc(title)}` - + m.yTicks.map((k) => `${esc(k.label)}`).join('') - + m.xLabels.map((l) => `${esc(l.label)}`).join('') + + m.yTicks.map((k) => `${esc(k.label)}`).join('') + + m.xLabels.map((l) => `${esc(l.label)}`).join('') + `${hasPrev ? `` : ''}${dots}
` } @@ -196,7 +219,9 @@ export function rankCardHtml(card, stats, { tab, expanded } = {}) { const def = CARDS[card] const active = def.tabs.some(([k]) => k === tab) ? tab : def.tabs[0][0] const items = list(stats?.[active]) - const sum = items.reduce((a, r) => a + num(r?.count), 0) + const listSum = items.reduce((a, r) => a + num(r?.count), 0) + // Anteil an allen Seitenaufrufen (die Liste zeigt hoechstens 20 Eintraege); Herkunft kennt nur die Summe der Liste (Besucher ohne Zustimmung haben keine) + const sum = active === 'refs' ? listSum : Math.max(listSum, num(stats?.totals?.pv)) const share = (c) => { const pct = sum > 0 ? (c / sum) * 100 : 0 return pct > 0 && pct < 0.5 ? '< 1 %' : `${Math.round(pct)} %` diff --git a/public/analyse-view.js b/public/analyse-view.js index 5b477d6..4e45b76 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -55,8 +55,8 @@ export function shortDay(day) { return m ? `${m[2]}.${m[1]}.` : String(day || '') } -/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. Mit compare ([{pv}], Zeitraum davor) gemeinsame Skala und gestrichelte Vergleichslinie (nach Tagesindex ausgerichtet). */ -export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, compare } = {}) { +/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. Mit compare ([{pv}], Zeitraum davor) gemeinsame Skala und gestrichelte Vergleichslinie (nach Tagesindex ausgerichtet). labelGap: Mindestabstand (Einheiten) zwischen der letzten und der vorletzten Beschriftung der x-Achse. */ +export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, labelGap = 36, compare } = {}) { const data = Array.isArray(series) ? series : [] const before = Array.isArray(compare) ? compare : [] const prevOf = (i) => { @@ -70,7 +70,7 @@ export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r const y = (v) => pad.t + innerH - (innerH * v) / top const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0, prev: prevOf(i) })) const step = Math.max(1, Math.ceil(data.length / maxLabels)) - const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > 36).map((p) => ({ x: p.x, label: shortDay(p.day) })) + const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > labelGap).map((p) => ({ x: p.x, label: shortDay(p.day) })) const yTicks = [0, 0.5, 1].map((f) => ({ y: round(y(top * f)), label: String(Math.round(top * f)) })) const line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ') const baseY = round(pad.t + innerH) @@ -165,13 +165,14 @@ export function periodLabel(from, to) { return a && b ? `${a.d}. ${MONTHS[a.m - 1]} – ${b.d}. ${MONTHS[b.m - 1]}` : '' } -/** Vergleich moeglich, wenn der Vorzeitraum vollstaendig ist und mindestens 20 Aufrufe hat. Sonst from = "TT.MM." ab dem der Vergleich geht (nur bei Vorzeitraum, der noch nicht voll ist). */ +/** Vergleich moeglich, wenn der Vorzeitraum vollstaendig ist und mindestens 20 Aufrufe hat. Sonst from = "TT.MM." ab dem der Vergleich geht (nur bei Vorzeitraum, der noch nicht voll ist). + Der Zeitraum endet gestern: der Vorzeitraum beginnt heute - 2 x range, also ist er ab firstDay + 2 x range voll. */ export function compareFrom(stats) { const prev = stats?.previous if (prev?.complete && Number(prev.totals?.pv) >= 20) return { ok: true, from: '' } const range = Number(stats?.range) if (prev && !prev.complete && stats.firstDay && range > 0) { - const from = addDays(stats.firstDay, 2 * range - 1) + const from = addDays(stats.firstDay, 2 * range) return { ok: false, from: from ? shortDay(from) : '' } } return { ok: false, from: '' } @@ -232,7 +233,7 @@ export function refName(host) { return hit ? hit[1] : h } -/** Staerkster Wochentag und Spitzentag ueber die Tage ab firstDay ('' = alle). null bei weniger als 7 Tagen. */ +/** Staerkster Wochentag und Spitzentag ueber die (abgeschlossenen) Tage der Reihe ab firstDay ('' = alle). null bei weniger als 7 Tagen. */ export function weekdayStats(series, firstDay) { const rows = (Array.isArray(series) ? series : []) .filter((d) => parseDay(d?.day) && (!firstDay || d.day >= firstDay)) @@ -255,7 +256,8 @@ export function weekdayStats(series, firstDay) { return { best, avg, peak } } -const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp', out: 'über einen Link nach außen' } +// Direkte Kontaktwege; Klicks auf Links nach außen ('out') sind kein Kontakt zu dir und zählen im Satz nicht mit +const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp' } const DEVICE_WORDS = { m: 'vom Handy', d: 'vom Computer' } // Satz ueber dem Hinweis-Block; nur hier steht fett (), alle Zahlen sind fmtNum @@ -280,17 +282,19 @@ function leadOf(stats, cmp) { } function contactItem(stats, cmp) { - const total = Number(stats.totals.contacts) || 0 + if (!Array.isArray(stats.contacts)) return null // Collector ohne Kontaktliste: nichts behaupten + const direct = (rows) => (Array.isArray(rows) ? rows : []).filter((c) => Object.hasOwn(CONTACT_WAYS, c?.key) && c.count > 0) + const sum = (rows) => rows.reduce((a, c) => a + c.count, 0) + const cur = direct(stats.contacts) + const total = sum(cur) if (total < 1) { return Number(stats.totals.visitors) >= 30 ? 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' : null } - const main = (Array.isArray(stats.contacts) ? stats.contacts : []) - .filter((c) => CONTACT_WAYS[c.key] && c.count > 0) - .reduce((m, c) => (!m || c.count > m.count ? c : m), null) - let text = `${fmtNum(total)}-mal hat dich jemand direkt kontaktiert${main ? `, meist ${CONTACT_WAYS[main.key]} (${fmtNum(main.count)})` : ''}.` - const before = cmp.ok ? Number(stats.previous.totals.contacts) : 0 + const main = cur.reduce((m, c) => (!m || c.count > m.count ? c : m), null) + let text = `${fmtNum(total)}-mal hat dich jemand direkt kontaktiert, meist ${CONTACT_WAYS[main.key]} (${fmtNum(main.count)}).` + const before = cmp.ok ? sum(direct(stats.previous.contacts)) : 0 if (before >= 1) { const diff = total - before text += diff === 0 ? ' Genauso oft wie zuvor.' : ` Das sind ${fmtNum(Math.abs(diff))} ${diff > 0 ? 'mehr' : 'weniger'} als zuvor.` @@ -298,16 +302,9 @@ function contactItem(stats, cmp) { return text } -// Der letzte Tag der Reihe ist heute und noch nicht zu Ende: er zaehlt beim Wochentag-Hinweis nicht mit -function completedDays(stats) { - const series = Array.isArray(stats.series) ? stats.series : [] - const last = series[series.length - 1] - return last && (!stats.to || last.day === stats.to) ? series.slice(0, -1) : series -} - function dayItem(stats) { if (!(Number(stats.totals.pv) >= 70)) return null - const w = weekdayStats(completedDays(stats), stats.firstDay || '') + const w = weekdayStats(stats.series, stats.firstDay || '') if (!w) return null const parts = [] if (w.best) parts.push(`${w.best.name} ist im Schnitt dein stärkster Tag.`) diff --git a/public/analyse.css b/public/analyse.css index e4e43eb..b00ba0c 100644 --- a/public/analyse.css +++ b/public/analyse.css @@ -5,7 +5,7 @@ --an-fg: #fff; --an-fg2: rgba(255, 255, 255, 0.78); --an-muted: rgba(255, 255, 255, 0.58); - --an-faint: rgba(255, 255, 255, 0.4); + --an-faint: rgba(255, 255, 255, 0.55); /* >= 4,5:1 auf der Karte (gemessen ca. 6,2:1) */ --an-line: rgba(255, 255, 255, 0.09); --an-line2: rgba(255, 255, 255, 0.18); --an-card: rgba(14, 16, 20, 0.92); @@ -38,6 +38,7 @@ .an-status-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--an-dot); box-shadow: 0 0 0 4px var(--an-dot-soft); } .an-site-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .an-status { display: block; margin-top: 2px; font-size: 12px; color: var(--an-muted); } +.an-st { white-space: nowrap; } .an-pick { display: block; max-width: 100%; padding: 2px 24px 2px 0; border: 0; border-radius: 6px; cursor: pointer; text-overflow: ellipsis; -webkit-appearance: none; appearance: none; color: var(--an-fg); font-family: inherit; font-size: 16px; font-weight: 700; line-height: 1.3; @@ -107,10 +108,21 @@ .an-leg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } .an-leg-line { display: inline-block; width: 18px; height: 0; border-top: 3px solid var(--an-acc); border-radius: 2px; } .an-leg-prev { border-top: 2px dashed var(--an-prev); } -.an-chartwrap { position: relative; flex: 1 1 auto; display: flex; align-items: center; min-height: 0; } +.an-chartwrap { position: relative; flex: 1 1 auto; display: flex; align-items: center; min-height: 0; container-type: inline-size; } .an-chart { display: block; width: 100%; height: auto; max-height: 100%; overflow: visible; touch-action: pan-y; } .an-grid { stroke: var(--an-line); stroke-width: 1px; vector-effect: non-scaling-stroke; } +/* Achsenschrift in viewBox-Einheiten (viewBox 640 breit, die Zeichnung skaliert mit der Kartenbreite). Je nach Breite der Zeichnung + so gewaehlt, dass sie immer ca. 11-12 px gross erscheint (gemessen: 320 px Handy bis 1920 px Desktop); Grundwert fuer Browser ohne Container-Abfragen. */ .an-axis { fill: var(--an-muted); font-size: 13px; } +@container (max-width: 279px) { .an-axis { font-size: 28px; } } +@container (min-width: 280px) and (max-width: 329px) { .an-axis { font-size: 24px; } } +@container (min-width: 330px) and (max-width: 379px) { .an-axis { font-size: 21px; } } +@container (min-width: 380px) and (max-width: 439px) { .an-axis { font-size: 18px; } } +@container (min-width: 440px) and (max-width: 499px) { .an-axis { font-size: 16px; } } +@container (min-width: 500px) and (max-width: 559px) { .an-axis { font-size: 14px; } } +@container (min-width: 560px) and (max-width: 629px) { .an-axis { font-size: 12.5px; } } +@container (min-width: 630px) and (max-width: 699px) { .an-axis { font-size: 11px; } } +@container (min-width: 700px) { .an-axis { font-size: 10px; } } .an-area { fill: var(--an-acc); opacity: 0.12; stroke: none; } .an-prev { fill: none; stroke: var(--an-prev); stroke-width: 2px; stroke-dasharray: 5 5; stroke-linejoin: round; vector-effect: non-scaling-stroke; } .an-line { fill: none; stroke: var(--an-acc); stroke-width: 2.4px; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; } @@ -196,6 +208,7 @@ .an-ghost:hover { background: rgba(255, 255, 255, 0.07); } .an-danger { color: #fca5a5; } .an .error { margin: 0 0 12px; text-align: left; } +#an-off p.error { color: #fca5a5; } /* ------------------------------------------- Einschalten und andere Zustaende --- */ .an-state p, .an-panel p { margin: 0 0 12px; line-height: 1.55; color: var(--an-fg2); } @@ -258,8 +271,12 @@ @media (max-width: 880px) { .an-top { padding: 14px; } .an-seg { display: flex; width: 100%; } - .an-seg button { flex: 1 1 0; padding: 9px 8px; } + .an-seg button { flex: 1 1 0; padding: 9px 8px; min-height: 40px; } .an-tabs { gap: 20px; margin: 14px 2px; } + /* Tippflaechen mindestens 40 px hoch */ + .an-tabbtn { min-height: 40px; padding-top: 8px; } + .an-ltab, .an-more, .an-btn { min-height: 40px; } + .an-ltab { display: inline-flex; align-items: center; padding: 6px 14px; } .an-card { padding: 16px; } .an-kpi { padding: 14px 14px 10px; } .an-v { font-size: 26px; } @@ -267,7 +284,6 @@ .an-sum { padding: 18px 16px; } .an-lead { font-size: 19px; } .an-sum-empty .an-lead { font-size: 16px; } - .an-axis { font-size: 17px; } .an-heat-bar label { max-width: none; } .an-bn { display: block; margin: 12px 0; padding: 16px; } .an-bn-row { flex-direction: column; margin-top: 12px; } diff --git a/public/analyse.js b/public/analyse.js index 2a96544..9d53995 100644 --- a/public/analyse.js +++ b/public/analyse.js @@ -48,10 +48,10 @@ export async function initAnalyse(root, { projectId } = {}) { else renderAnalyseActivation(stage, project, reload) } -// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens -function headHtml(project, usable, { showRange, stamp = '' } = {}) { +// Kopfleiste (analyse-cards.js); Zeitraum nur in der Uebersicht, "Stand" = Uhrzeit des letzten Ladens, today = "Heute bisher" aus der Statistik +function headHtml(project, usable, { showRange, stamp = '', today } = {}) { const a = analyseOf(project) - return headerHtml({ project, usable, range: RANGES.includes(analyseSel.range) ? analyseSel.range : 30, showRange, devMode: a.state === 'active' && Boolean(a.devMode), stamp }) + return headerHtml({ project, usable, range: RANGES.includes(analyseSel.range) ? analyseSel.range : 30, showRange, devMode: a.state === 'active' && Boolean(a.devMode), stamp, today }) } function bannerPreview(color) { @@ -182,7 +182,7 @@ function mountCockpit({ an, stage, project, usable, reload }) { const bar = an.querySelector('.an-top') if (!bar) return const focused = bar.contains(document.activeElement) ? document.activeElement.dataset.range : '' - bar.outerHTML = headHtml(project, usable, { showRange: tabNow() === 'uebersicht', stamp: state.stamp }) + bar.outerHTML = headHtml(project, usable, { showRange: tabNow() === 'uebersicht', stamp: state.stamp, today: state.stats?.today }) if (focused) an.querySelector(`.an-seg [data-range="${focused}"]`)?.focus() } // Reiterknoepfe in place umschalten (Fokus bleibt), nur der gewaehlte ist per Tab erreichbar, die anderen per Pfeiltaste diff --git a/test/analyseCards.test.js b/test/analyseCards.test.js index 00a24bd..2be00c0 100644 --- a/test/analyseCards.test.js +++ b/test/analyseCards.test.js @@ -16,6 +16,7 @@ const prev = { } const stats = (over = {}) => ({ range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01', + today: { day: '2026-10-05', pv: 42, visitors: 20, contacts: 1 }, totals: t, series: rich(days('2026-09-05', 30, (i) => (i === 14 ? 371 : 231))), pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }], @@ -76,7 +77,7 @@ test('summaryHtml: Satz und Hinweise mit Symbol; Leerzustand unter 30 Aufrufen', assert.match(h, /Deine Website wurde in den letzten 30 Tagen 6\.930-mal<\/b> aufgerufen/) assert.equal(count(h, /
  • /g), 4); assert.match(h, /
      { assert.match(h, /aria-label="plus 14 Prozent gegenüber dem Zeitraum davor"/) assert.match(h, /Besucher\*<\/span>/) assert.equal(count(h, / ({ ...d, avgSec: i === 0 ? null : 100 })) })) assert.equal(count(noTime, / /data-kpi="time".*?class="an-spark-line" d="([^"]*)"/s.exec(html)[1] + assert.match(timeLine(noTime), /^M6\.9 /) // der erste Tag (x = 0) fehlt, die Linie beginnt am zweiten + assert.equal(count(timeLine(noTime), /M/g), 1) + assert.doesNotMatch(timeLine(noTime), /M0 |L0 /) + assert.match(timeLine(kpisHtml(full)), /^M0 /) // vollständige Reihe: Linie ab dem ersten Tag + // Zählwerte behalten 0: ein Tag ohne Aufrufe ist ein echter Tiefpunkt + const zeroPv = kpisHtml(stats({ series: full.series.map((d, i) => ({ ...d, pv: i === 0 ? 0 : 231 })) })) + assert.match(/data-kpi="pv".*?class="an-spark-line" d="([^"]*)"/s.exec(zeroPv)[1], /^M0 36 /) // Reihe ohne avgSec-Feld: kein Verlauf bei der Zeit assert.equal(count(kpisHtml(stats({ series: full.series.map(({ avgSec, ...d }) => d) })), / { assert.match(none, /–<\/b>/); assert.doesNotMatch(none, /gescrollt|vs\. –/) }) -test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => { +test('kpisHtml: neue Website zeigt "Vergleich ab 19.11."', () => { const h = kpisHtml(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })) - assert.equal(count(h, /Vergleich ab 18\.11\./g), 4) + assert.equal(count(h, /Vergleich ab 19\.11\./g), 4) assert.doesNotMatch(h, /an-delta|vs\./) const k7 = kpisHtml(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })) - assert.match(k7, /Vergleich ab 11\.10\./) + assert.match(k7, /Vergleich ab 12\.10\./) // vollständiger Vorzeitraum, aber unter 20 Aufrufen: weder Chip noch Datum const tiny = kpisHtml(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 5 } } })) assert.doesNotMatch(tiny, /an-delta|vs\.|Vergleich ab/) @@ -129,6 +138,22 @@ test('sparkHtml: Fläche und Linie, nichts bei weniger als 2 Werten, kein NaN', assert.doesNotMatch(sparkHtml([3, null, undefined, 'x']), /NaN|undefined|null/) }) +test('sparkHtml: Lücken (null) unterbrechen die Linie statt als 0 zu gelten', () => { + const line = (h) => /class="an-spark-line" d="([^"]*)"/.exec(h)[1] + const area = (h) => /class="an-spark-area" d="([^"]*)"/.exec(h)[1] + // Wert 0 liegt auf der Grundlinie (y = 36); eine Lücke darf dort keinen Punkt erzeugen + const gap = sparkHtml([4, 6, null, 6, 8]) + assert.equal(count(line(gap), /M/g), 2) // zwei Abschnitte: Tage 0-1 und 3-4 + assert.doesNotMatch(line(gap), / 36( |$)/) + assert.equal(count(area(gap), /Z/g), 2) // Fläche je Abschnitt + assert.doesNotMatch(line(gap), /(^|[ML])100 /) // der Tag mit Lücke (x = 100) hat keinen Punkt + assert.match(line(sparkHtml([null, 5, 7, 9])), /^M66\.7 /) // Lücke am Anfang: Linie beginnt später, nicht bei 0 + assert.equal(sparkHtml([null, null, null]), ''); assert.equal(sparkHtml([null, 4]), '') // unter 2 Messwerten nichts + assert.doesNotMatch(sparkHtml([4, null, 6]), /NaN|undefined|null/) + // alle Werte vorhanden: ein Abschnitt wie bisher + assert.equal(count(line(sparkHtml([1, 5, 3, 8])), /M/g), 1) +}) + test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte mit Daten', () => { const h = chartHtml(full) assert.match(h, /Seitenaufrufe pro Tag vom 05\.09\. bis 04\.10\., insgesamt 7\.\d{3}<\/title>/) @@ -146,6 +171,27 @@ test('chartHtml: Linie, Fläche, Vorzeitraum nur mit Vergleich, Legende, Punkte assert.doesNotMatch(chartHtml(zero), /NaN|Infinity|undefined|null/) }) +test('chartHtml: Achsenbeschriftung hat Platz – y-Rand wächst mit der Stellenzahl, x-Beschriftungen überlappen nicht', () => { + const padOf = (html) => Number(/class="an-grid" x1="([\d.]+)"/.exec(html)[1]) + const series = (pv, n = 30) => days('2026-09-05', n, pv).map((d) => ({ ...d, visitors: 1, contacts: 0, avgSec: 1 })) + const none = { previous: { ...prev, complete: false } } + // Skala 500 (3 Zeichen), 5.000 (4), 20.000 (5), 50 (2): Platz für die Ziffern der y-Beschriftung + assert.equal(padOf(chartHtml(stats({ series: series(300), ...none }))), 49) + assert.equal(padOf(chartHtml(stats({ series: series(2400), ...none }))), 64) + assert.equal(padOf(chartHtml(stats({ series: series(12000), ...none }))), 78) + assert.equal(padOf(chartHtml(stats({ series: series(30), ...none }))), 40) + // y-Beschriftung steht rechtsbündig 6 Einheiten vor dem Raster und ist an der Gitterlinie zentriert + const h = chartHtml(stats({ series: series(2400), ...none })) + assert.match(h, /<text class="an-axis" x="58" y="[\d.]+" dy="0\.35em" text-anchor="end">5000<\/text>/) + // x-Beschriftungen: nie näher als 100 Einheiten (Platz für "29.09." in der größten Schrift), letzter Tag bleibt + for (const n of [7, 30, 90]) { + const xs = [...chartHtml(stats({ series: series(300, n), ...none })).matchAll(/class="an-axis" x="([\d.]+)" y="[\d.]+" text-anchor="middle"/g)].map((m) => Number(m[1])) + assert.ok(xs.length >= 3, `range ${n}`) + assert.ok(xs.every((x, i) => !i || x - xs[i - 1] >= 100), `range ${n}: ${xs}`) + assert.equal(xs[xs.length - 1], 616) // 640 - rechter Rand 24: der letzte Tag + } +}) + test('chartTipText: Blase mit und ohne Vorwert', () => { assert.equal(chartTipText('2026-09-19', 371, 210), 'Sa., 19.09.: 371 Aufrufe (davor 210)') assert.equal(chartTipText('2026-09-19', '371', '210'), 'Sa., 19.09.: 371 Aufrufe (davor 210)') // Werte aus data-Attributen sind Strings @@ -196,7 +242,7 @@ test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, assert.match(h, /<img src=x>/); assert.doesNotMatch(h, /<img src=x>/) const pages = rankCardHtml('seen', full) assert.match(pages, /Startseite/); assert.doesNotMatch(pages, />\/</) - assert.match(pages, /<b>3\.200<\/b><em>59 %<\/em>/) // 3200 von 5410 + assert.match(pages, /<b>3\.200<\/b><em>46 %<\/em>/) // 3200 von 6930 Aufrufen (die Liste zeigt nur 5410 davon) assert.match(pages, /data-rank="seen" data-rank-active="pages"/) assert.match(pages, /<h3>Was angesehen wird<\/h3>/) assert.match(pages, /data-rank-tab="pages" aria-pressed="true"/); assert.match(pages, /data-rank-tab="refs" aria-pressed="false"/) @@ -228,6 +274,17 @@ test('rankCardHtml: Geräte, Länder mit Flagge, ungültiger Reiter, leere Liste const tiny = rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 10000 }, { key: '/b', count: 1 }] })) assert.match(tiny, /<em>< 1 %<\/em>/) assert.doesNotMatch(rankCardHtml('seen', stats({ pages: [{ key: '/a', count: 0 }] })), /NaN|Infinity/) + // Nenner: Seiten, Länder, Geräte relativ zu max(Summe der Liste, Seitenaufrufe); Herkunft nur zur Summe der Liste + const part = stats({ totals: { ...t, pv: 1000 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }], countries: [{ key: 'DE', count: 250 }], devices: [{ key: 'm', count: 500 }], refs: [{ key: 'google.com', count: 30 }, { key: '', count: 10 }] }) + assert.match(rankCardHtml('seen', part), /<b>300<\/b><em>30 %<\/em>/) // 300 von 1000, nicht von 400 (75 %) + assert.match(rankCardHtml('who', part, { tab: 'countries' }), /<b>250<\/b><em>25 %<\/em>/) + assert.match(rankCardHtml('who', part), /<b>500<\/b><em>50 %<\/em>/) + assert.match(rankCardHtml('seen', part, { tab: 'refs' }), /<b>30<\/b><em>75 %<\/em>/) // Herkunft: 30 von 40 + // Liste größer als die Gesamtzahl (Zählungen laufen auseinander): die Summe, nie mehr als 100 % + const over = stats({ totals: { ...t, pv: 100 }, pages: [{ key: '/a', count: 300 }, { key: '/b', count: 100 }] }) + assert.match(rankCardHtml('seen', over), /<b>300<\/b><em>75 %<\/em>/) + // ohne totals (alter Collector): Summe der Liste + assert.match(rankCardHtml('seen', { pages: [{ key: '/a', count: 3 }, { key: '/b', count: 1 }] }), /<em>75 %<\/em>/) // Länder-Schlüssel maskiert const bad = rankCardHtml('who', stats({ countries: [{ key: '<b>', count: 3 }] }), { tab: 'countries' }) assert.match(bad, /Unbekannt/); assert.doesNotMatch(bad, /<b><|<b>&/); assert.doesNotMatch(bad, /an-flag/) @@ -247,26 +304,35 @@ test('basisHtml: Ring, Sätze mit und ohne Entscheidungen', () => { test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => { const a = project() const b = { projectId: 'p2', projectName: 'Café "Zum <Bären>"' } - const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42' }) + const one = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: false, stamp: '19:42', today: { day: '2026-10-05', pv: 1234, visitors: 80, contacts: 3 } }) assert.doesNotMatch(one, /an-project|<select/); assert.match(one, /<b class="an-site-name">Boulderhalle Überhang<\/b>/) - assert.match(one, /Analyse aktiv · Stand: heute, 19:42 Uhr/) + assert.match(one, /<small class="an-status"><span class="an-st">Analyse aktiv<\/span> · <span class="an-st">Heute bisher: 1\.234 Aufrufe<\/span> · <span class="an-st">Stand: 19:42 Uhr<\/span><\/small>/) assert.match(one, /<button type="button" class="on" data-range="30" aria-pressed="true">30 Tage<\/button>/) assert.equal(count(one, /data-range="/g), 3); assert.match(one, /data-range="7"[^>]*aria-pressed="false"/) const two = headerHtml({ project: b, usable: [a, b], range: 7, showRange: false, devMode: false, stamp: '' }) assert.match(two, /<select id="an-project"/); assert.match(two, /<option value="p2" selected>Café "Zum <Bären>"<\/option>/) assert.match(two, /<option value="p1">Boulderhalle Überhang<\/option>/) assert.doesNotMatch(two, /data-range/); assert.doesNotMatch(two, /Zum <Bären>/); assert.doesNotMatch(two, /Stand:/) - assert.match(two, /Analyse aktiv/) + assert.match(two, /Analyse aktiv/); assert.doesNotMatch(two, /Heute bisher/) // ohne today (alter Collector) entfällt der Teil assert.match(headerHtml({ project: a, usable: [a], range: 365, showRange: true }), /class="on" data-range="30"/) // unbekannter Zeitraum => 30 // Dev-Modus const dev = headerHtml({ project: a, usable: [a], range: 30, showRange: true, devMode: true, stamp: '19:42' }) - assert.match(dev, /Im Dev-Modus wird nicht gemessen\./); assert.doesNotMatch(dev, /Analyse aktiv|Stand:/); assert.match(dev, /data-state="warn"/) + assert.match(dev, /Im Dev-Modus wird nicht gemessen\./); assert.doesNotMatch(dev, /Analyse aktiv|Stand:|Heute bisher/); assert.match(dev, /data-state="warn"/) assert.match(one, /data-state="on"/) // Name maskiert const evil = headerHtml({ project: { projectId: 'x', projectName: '<img src=x onerror=alert(1)>' }, usable: [], range: 30, showRange: false }) assert.doesNotMatch(evil, /<img/); assert.match(evil, /<img src=x onerror=alert\(1\)>/) - const odd = headerHtml({ project: { projectId: 'p"1', projectName: 'A' }, usable: [{ projectId: 'p"1', projectName: 'A' }, { projectId: 'p2', projectName: 'B' }], stamp: '<b>x</b>' }) - assert.match(odd, /value="p"1" selected/); assert.match(odd, /heute, <b>x<\/b> Uhr/); assert.doesNotMatch(odd, /<b>x/) + const odd = headerHtml({ project: { projectId: 'p"1', projectName: 'A' }, usable: [{ projectId: 'p"1', projectName: 'A' }, { projectId: 'p2', projectName: 'B' }], stamp: '<b>x</b>', today: { pv: '<i>5</i>' } }) + assert.match(odd, /value="p"1" selected/); assert.match(odd, /Stand: <b>x<\/b> Uhr/); assert.doesNotMatch(odd, /<b>x|<i>/) + // Heute bisher: Einzahl, 0 und nur mit Zahl; ohne Uhrzeit nur der Zähler + const today = (pv, stamp = '') => headerHtml({ project: a, usable: [a], range: 30, showRange: false, stamp, today: { day: '2026-10-05', pv, visitors: 0, contacts: 0 } }) + const plain = (h) => /<small class="an-status">(.*?)<\/small>/.exec(h)[1].replace(/<[^>]+>/g, '') // Text ohne die Umbruch-Spans + assert.equal(plain(today(1, '08:05')), 'Analyse aktiv · Heute bisher: 1 Aufruf · Stand: 08:05 Uhr') + assert.match(today(0, '08:05'), /Heute bisher: 0 Aufrufe/) + assert.equal(plain(today(42)), 'Analyse aktiv · Heute bisher: 42 Aufrufe') + assert.doesNotMatch(headerHtml({ project: a, usable: [a], today: {} }), /Heute bisher|NaN/) // today ohne pv: nichts behaupten + // Zustände ohne Messung zeigen kein "Heute bisher" + assert.doesNotMatch(headerHtml({ project: project({ analyse: { state: 'disabled' } }), usable: [], today: { pv: 5 } }), /Heute bisher/) // Zustände ohne Messung sagen nicht "aktiv" const off = headerHtml({ project: project({ analyse: { state: 'off', eligible: true } }), usable: [], range: 30, showRange: false }) assert.doesNotMatch(off, /Analyse aktiv/); assert.match(off, /data-state="off"/) diff --git a/test/analyseInsights.test.js b/test/analyseInsights.test.js index 47b5c78..8d1e52c 100644 --- a/test/analyseInsights.test.js +++ b/test/analyseInsights.test.js @@ -14,6 +14,7 @@ const prev = { } const stats = (over = {}) => ({ range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01', + today: { day: '2026-10-05', pv: 42, visitors: 20, contacts: 1 }, totals: t, series: days('2026-09-05', 30, 231), pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }], @@ -66,8 +67,8 @@ test('fmtDuration, dayLabel, periodLabel, fmtNum', () => { 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(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })), { ok: false, from: '11.10.' }) // range 7: +13 Tage + assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '19.11.' }) // range 30: +60 Tage (2 × range, der erste Tag mit vollem Vorzeitraum) + assert.deepEqual(compareFrom(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })), { ok: false, from: '12.10.' }) // range 7: +14 Tage assert.deepEqual(compareFrom(stats({ firstDay: '', previous: { ...prev, complete: false } })), { ok: false, from: '' }) assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' }) assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false) @@ -116,7 +117,7 @@ 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.equal(s.items[0].html, '178-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 35 mehr als zuvor.') // Telefon + E-Mail + WhatsApp: 96 + 52 + 30 gegen 80 + 40 + 23 assert.ok(s.items.length <= 4) }) @@ -148,7 +149,7 @@ test('summarize: ohne Vergleich und neu gestartet', () => { // auch das Startdatum wird maskiert, falls der Collector je Unsinn liefert assert.ok(!summarize(stats({ firstDay: '<i>x</i>', previous: { ...prev, complete: false } })).lead.includes('<i>')) // ohne Vergleich kein „Das sind … mehr als zuvor“ - assert.equal(neu.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).') + assert.equal(neu.items[0].html, '178-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).') // 7 Tage: „7 Tagen“ assert.match(summarize(stats({ range: 7 })).lead, /in den letzten 7 Tagen .* als in den 7 Tagen davor\.$/) }) @@ -164,16 +165,26 @@ test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 B assert.notEqual(wenig.items[0].icon, 'contact') }) -test('summarize: Kontakte – Vergleichssatz und häufigste Art', () => { - const html = (contactsNow, contactsBefore, kinds) => summarize(stats({ - totals: { ...t, contacts: contactsNow }, +test('summarize: Kontakte – Vergleichssatz und häufigste Art, Links nach außen zählen nicht mit', () => { + const tel = (n) => ({ key: 'tel', count: n }) + const html = (kinds, before) => summarize(stats({ + totals: { ...t, contacts: kinds.reduce((a, k) => a + k.count, 0) }, contacts: kinds, - previous: { ...prev, totals: { ...prev.totals, contacts: contactsBefore } }, + previous: { ...prev, contacts: before, totals: { ...prev.totals, contacts: before.reduce((a, k) => a + k.count, 0) } }, })).items[0].html - assert.equal(html(188, 200, [{ key: 'tel', count: 96 }]), '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 12 weniger als zuvor.') - assert.equal(html(188, 188, [{ key: 'mail', count: 100 }]), '188-mal hat dich jemand direkt kontaktiert, meist per E-Mail (100). Genauso oft wie zuvor.') - assert.equal(html(5, 0, [{ key: 'wa', count: 3 }]), '5-mal hat dich jemand direkt kontaktiert, meist per WhatsApp (3).') // Vorwert 0: kein Vergleichssatz - assert.equal(html(5, 2, [{ key: 'out', count: 3 }]), '5-mal hat dich jemand direkt kontaktiert, meist über einen Link nach außen (3). Das sind 3 mehr als zuvor.') + assert.equal(html([tel(96)], [tel(108)]), '96-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 12 weniger als zuvor.') + assert.equal(html([{ key: 'mail', count: 100 }], [{ key: 'mail', count: 100 }]), '100-mal hat dich jemand direkt kontaktiert, meist per E-Mail (100). Genauso oft wie zuvor.') + assert.equal(html([{ key: 'wa', count: 5 }], []), '5-mal hat dich jemand direkt kontaktiert, meist per WhatsApp (5).') // Vorwert 0: kein Vergleichssatz + assert.equal(html([tel(5)], [tel(2)]), '5-mal hat dich jemand direkt kontaktiert, meist per Telefon (5). Das sind 3 mehr als zuvor.') + // Links nach außen sind Klicks auf andere Seiten, kein Kontakt: weder in der Zahl noch als häufigste Art, auch nicht im Vorzeitraum + assert.equal(html([{ key: 'out', count: 300 }, tel(40)], [{ key: 'out', count: 900 }, tel(40)]), '40-mal hat dich jemand direkt kontaktiert, meist per Telefon (40). Genauso oft wie zuvor.') + assert.equal(html([{ key: 'out', count: 300 }, tel(40)], [{ key: 'out', count: 900 }]), '40-mal hat dich jemand direkt kontaktiert, meist per Telefon (40).') + // nur Links nach außen (Besucher ≥ 30): wie „keine Kontakte“ + const nurOut = summarize(stats({ totals: { ...t, contacts: 300, visitors: 400 }, contacts: [{ key: 'out', count: 300 }] })) + assert.deepEqual(nurOut.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' }) + // Collector ohne Kontaktliste: kein Satz, der etwas Falsches behauptet + const { contacts, ...ohneListe } = stats({ totals: { ...t, contacts: 0, visitors: 400 } }) + assert.notEqual(summarize(ohneListe).items[0].icon, 'contact') }) test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => { @@ -219,30 +230,24 @@ test('summarize: Wochentag und Spitzentag in einem Hinweis', () => { assert.ok(!summarize(stats({ firstDay: '2026-09-30', series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day')) }) -test('summarize: der heutige, unvollständige Tag zählt beim Wochentag-Hinweis nicht mit', () => { +test('summarize: Wochentag-Hinweis zählt alle Tage der Reihe (alle sind abgeschlossen), auch in der 7-Tage-Ansicht', () => { const isSat = (day) => new Date(day + 'T00:00:00Z').getUTCDay() === 6 const dayHtml = (s) => s.items.find((i) => i.icon === 'day')?.html - // Reihe 04.09.–03.10.2026, heute = Samstag 03.10. mit erst 5 Aufrufen; Samstage sonst 280, alle anderen Tage 200 - const today = '2026-10-03' - const sat = (i, day) => (day === today ? 5 : isSat(day) ? 280 : 200) - const series = days('2026-09-04', 30, sat) - const base = { from: '2026-09-04', to: today, series } - // zählte der Tag mit, läge der Samstag-Schnitt (225) unter 1,2 × Gesamtschnitt (204) → kein Hinweis - assert.equal(weekdayStats(series, '').best, null) - // ohne den heutigen Tag: Samstag Ø 280 ≥ 1,2 × 211, stärkster Tag; Spitzentag nein (280 < 1,8 × 211) - assert.equal(dayHtml(summarize(stats(base))), 'Samstag ist im Schnitt dein stärkster Tag.') - // Spitzentag: Mi., 16.09. mit 365 (zählte heute mit, wäre 365 ≥ 1,8 × 199 ein Spitzentag; ohne heute 365 < 1,8 × 205,7) - const wed = days('2026-09-04', 30, (i, day) => (day === today ? 5 : day === '2026-09-16' ? 365 : 200)) - assert.ok(weekdayStats(wed, '').peak) - assert.equal(dayHtml(summarize(stats({ ...base, series: wed }))), undefined) - // fehlt `to`, entfällt der letzte Tag trotzdem - assert.equal(dayHtml(summarize(stats({ ...base, to: undefined }))), 'Samstag ist im Schnitt dein stärkster Tag.') - // endet die Reihe nicht auf `to`, ist der letzte Tag ein abgeschlossener Tag und zählt mit - assert.equal(dayHtml(summarize(stats({ ...base, to: '2026-10-04' }))), undefined) - // 7-Tage-Ansicht: nur 6 abgeschlossene Tage → kein Wochentag-Hinweis, auch bei deutlichem Muster + // Reihe 04.09.–03.10.2026 (Ende = gestern, heute steht getrennt in `today`); der letzte Tag, Samstag 03.10., zählt mit + const last = '2026-10-03' + const base = { from: '2026-09-04', to: last, today: { day: '2026-10-04', pv: 5, visitors: 3, contacts: 0 } } + assert.equal(dayHtml(summarize(stats({ ...base, series: days('2026-09-04', 30, (i, day) => (isSat(day) ? 280 : 200)) }))), 'Samstag ist im Schnitt dein stärkster Tag.') + // Spitzentag am letzten Tag der Reihe wird erkannt + const spike = days('2026-09-04', 30, (i, day) => (day === last ? 400 : 100)) + assert.equal(dayHtml(summarize(stats({ ...base, series: spike }))), 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 03.10. mit 400 Aufrufen.') + // 7-Tage-Ansicht: genau 7 abgeschlossene Tage reichen für den Hinweis (28.09.–04.10., Sa. 03.10.) const week = days('2026-09-28', 7, (i, day) => (isSat(day) ? 500 : 100)) - assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', series: week }))), undefined) - assert.ok(weekdayStats(week, '')) // mit allen 7 Tagen wäre es ein Ergebnis gewesen + assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', series: week }))), 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 03.10. mit 500 Aufrufen.') + // weniger als 7 Tage (neue Website): weiter kein Hinweis + assert.equal(dayHtml(summarize(stats({ range: 7, from: '2026-09-28', to: '2026-10-04', firstDay: '2026-09-29', series: week }))), undefined) + // alter Collector ohne `today`: derselbe Hinweis, kein Absturz + const { today, ...alt } = stats({ ...base, series: days('2026-09-04', 30, (i, day) => (isSat(day) ? 280 : 200)) }) + assert.equal(dayHtml(summarize(alt)), 'Samstag ist im Schnitt dein stärkster Tag.') }) test('summarize: Pfade und Hosts werden maskiert', () => { diff --git a/test/analyseView.test.js b/test/analyseView.test.js index 8628c0e..6e00979 100644 --- a/test/analyseView.test.js +++ b/test/analyseView.test.js @@ -37,6 +37,13 @@ test('Diagramm-Skalierung', () => { assert.ok(m.xLabels.length <= 7 && m.xLabels.length >= 2) assert.equal(chartModel([]).points.length, 0) assert.equal(chartModel([{ day: '2026-10-01', pv: 3 }]).points.length, 1) + // labelGap: die vorletzte Beschriftung rückt weg, wenn sie der letzten zu nahe kommt (30 Tage: 29.09. ist 79 Einheiten vor 03.10.) + const labels = (opts) => chartModel(series, { width: 640, ...opts }).xLabels.map((l) => l.label) + assert.equal(labels({}).length, 7) + const wide = chartModel(series, { labelGap: 90 }).xLabels + assert.equal(wide.length, 6) + assert.ok(wide.every((l, i, a) => !i || l.x - a[i - 1].x >= 90)) + assert.equal(wide[wide.length - 1].label, '30.09.') // letzter Tag bleibt }) test('Balkenzeilen', () => { diff --git a/test/analyticsContract.test.js b/test/analyticsContract.test.js index 4df3a50..1e13065 100644 --- a/test/analyticsContract.test.js +++ b/test/analyticsContract.test.js @@ -4,6 +4,8 @@ import assert from 'node:assert/strict' import { createAnalyticsClient } from '../server/services/analyticsClient.js' import { createAdminServer, createPublicServer } from '../analytics/src/server.js' import { openDb } from '../analytics/src/db.js' +import { computeStats } from '../analytics/src/stats.js' +import { compareFrom, shortDay } from '../public/analyse-view.js' const SITE = 'abcdef0123456789abcd' const DOMAIN = 'kunde.example.com' @@ -39,9 +41,18 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as await post({ t: 'ct', path: '/', kind: 'tel' }) await post({ t: 'bn', a: 'accept' }) + // Frisch gemessen: heute steht nur in `today`, der Zeitraum sind die abgeschlossenen Tage bis gestern + const jetzt = await client.getStats(SITE, '7') + assert.deepEqual(jetzt.today, { day: jetzt.today.day, pv: 3, visitors: 1, contacts: 1 }) + assert.equal(jetzt.totals.pv, 0) + assert.ok(jetzt.today.day > jetzt.to && jetzt.series.every((d) => d.day <= jetzt.to)) + // Um 36 Stunden zurückdatieren: liegt immer auf einem abgeschlossenen Tag des 7-Tage-Zeitraums + db.prepare('UPDATE events_raw SET ts = ts - 36 * 3600 * 1000 WHERE site = ?').run(SITE) + const stats = await client.getStats(SITE, '7') assert.equal(stats.totals.pv, 3) assert.equal(stats.totals.contacts, 1) + assert.equal(stats.today.pv, 0) assert.equal(stats.series.length, 7) assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3) assert.equal(stats.range, 7) @@ -76,3 +87,22 @@ test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledi assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince) assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true }) }) + +test('Vertrag: "Vergleich ab" des Portals ist der erste Tag, an dem der Collector den Vorzeitraum als vollständig meldet', () => { + const firstDay = '2026-09-20' + const evDb = openDb(':memory:') + evDb.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(SITE, 'p1', '["a.de"]') + evDb.prepare("INSERT INTO events_raw (site, ts, type, path, vp, country, device) VALUES (?,?,'pv','/','d','DE','d')").run(SITE, Date.UTC(2026, 8, 20, 10)) + for (const range of [7, 30]) { + const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10) + const at = (day) => computeStats(evDb, SITE, range, Date.parse(`${day}T10:00:00Z`)) + const first = addDays(firstDay, 2 * range) // "Vergleich ab" laut Portal + const dayBefore = at(addDays(first, -1)) + assert.equal(dayBefore.previous.complete, false, `range ${range}: am Tag davor noch nicht vollständig`) + assert.deepEqual(compareFrom(dayBefore), { ok: false, from: shortDay(first) }) + const onDay = at(first) + assert.equal(onDay.previous.complete, true, `range ${range}: am Tag selbst vollständig`) + assert.equal(onDay.previous.from, firstDay) + assert.equal(compareFrom(onDay).from, '') // kein Datum mehr (der Vorzeitraum ist voll; ok bleibt false, denn es gibt nur 1 Aufruf) + } +}) diff --git a/test/ui/analyse.ui.mjs b/test/ui/analyse.ui.mjs index a896dbc..92895fe 100644 --- a/test/ui/analyse.ui.mjs +++ b/test/ui/analyse.ui.mjs @@ -47,11 +47,13 @@ const BEFORE = { } const SCALE = { 7: 0.25, 30: 1, 90: 3 } // Anteil der 30-Tage-Zahlen je Zeitraum const PAGES = [['/', 2480], ['/preise', 1310], ['/kurse', 1150], ['/routen', 960], ['/oeffnungszeiten', 640], ['/team', 410], ['/kontakt', 300], ['/impressum', 120], ['/datenschutz', 90]] +// Der Zeitraum endet gestern (abgeschlossene Tage); heute steht getrennt in `today` function makeStats(range, over = {}) { - const from = addDays(TODAY, -(range - 1)) + const from = addDays(TODAY, -range) const pfrom = addDays(from, -range) return { - range, from, to: TODAY, firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)), + range, from, to: addDays(TODAY, -1), firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)), + today: { day: TODAY, pv: 42, visitors: 17, contacts: 2 }, pages: PAGES.map(([key, count]) => ({ key, count: Math.round(count * SCALE[range]) })), refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }].map((r) => ({ ...r, count: Math.round(r.count * SCALE[range]) })), countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: 'CH', count: 330 }], @@ -79,6 +81,8 @@ const SCENARIOS = { wenig: { project: project(), stats: (r) => makeStats(r, { totals: { ...TOTALS[r], pv: 12 } }) }, dev: { project: project({ devMode: true }), stats: (r) => makeStats(r) }, alt: { project: project(), stats: (r) => OLD(r) }, + stark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 2400, Math.min(14, r - 1)) }) }, + sehrStark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 12000, Math.min(14, r - 1)) }) }, langerPfad: { project: project(), stats: (r) => makeStats(r, { pages: [{ key: longPath, count: 900 }, { key: '/kurz', count: 500 }], refs: [{ key: `${'h'.repeat(90)}.example.com`, count: 300 }] }) }, fehler: { project: project(), stats: null }, zwei: { projects: [project(), { ...baseProject, projectId: 'p2', projectName: 'Zweite Website mit einem wirklich sehr langen Namen, der nicht in eine Zeile passt', analyse: analyse({}) }], stats: (r) => makeStats(r) }, @@ -168,7 +172,7 @@ try { await open('full') check(L('aktiv: Block "Das Wichtigste" mit "14 % mehr"'), (await text('.an-sum')).includes('Das Wichtigste') && (await text('.an-sum')).includes('14 % mehr')) check(L('aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼'), (await count('.an-kpis .an-kpi')) === 4 && (await count('.an-kpis .an-delta.up')) === 3 && (await count('.an-kpis .an-delta.down')) === 1 && (await text('.an-kpis')).includes('▲') && (await text('.an-kpis')).includes('▼')) - check(L('aktiv: Kopfleiste mit Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /Analyse aktiv · Stand: heute, \d\d:\d\d Uhr/.test(await text('.an-status')) && (await page.getAttribute('.an-seg .on', 'data-range')) === '30' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht') + check(L('aktiv: Kopfleiste mit "Heute bisher: 42 Aufrufe" und Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /^Analyse aktiv · Heute bisher: 42 Aufrufe · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')) && (await page.getAttribute('.an-seg .on', 'data-range')) === '30' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht') check(L('kein_horizontaler_scroll aktiv'), await noScroll()) // Raster: Desktop 4 Kacheln nebeneinander, Handy 2 x 2; Diagramm und Kontaktkarte gleich hoch (Desktop) const tiles = await page.$$eval('.an-kpis .an-kpi', (els) => els.map((e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width) } })) @@ -208,6 +212,28 @@ try { } await page.waitForFunction(() => document.querySelector('.an-tip')?.hidden === true) check(L('Diagramm-Blase: verschwindet wieder, kein Punkt bleibt markiert'), (await count('.an-dot.on')) === 0) + // Achsenbeschriftung: gerendert ca. 11-12 px (viewBox 640 skaliert mit der Breite), nichts ragt aus der Karte, x-Beschriftungen überlappen nicht + const axis = () => page.evaluate(() => { + const svg = document.querySelector('.an-chart') + const scale = svg.getBoundingClientRect().width / 640 + const box = document.querySelector('.an-chartbox').getBoundingClientRect() + const texts = [...svg.querySelectorAll('.an-axis')].map((t) => ({ r: t.getBoundingClientRect(), px: parseFloat(getComputedStyle(t).fontSize) * scale, anchor: t.getAttribute('text-anchor'), s: t.textContent })) + const xs = texts.filter((t) => t.anchor === 'middle').sort((a, b) => a.r.left - b.r.left) + return { + px: Math.min(...texts.map((t) => t.px)), pxMax: Math.max(...texts.map((t) => t.px)), + inside: texts.every((t) => t.r.left >= box.left + 1 && t.r.right <= box.right - 1), + gap: xs.every((t, i) => !i || t.r.left >= xs[i - 1].r.right + 4), + y: texts.filter((t) => t.anchor === 'end').map((t) => t.s).join(','), + } + }) + const ax = await axis() + check(L(`Achsenbeschriftung ca. 11-12 px (${ax.px.toFixed(1)} px), liegt in der Karte, x-Werte überlappen nicht`), ax.px >= 10.5 && ax.pxMax <= 13 && ax.inside && ax.gap) + const faint = await page.evaluate(() => getComputedStyle(document.querySelector('.an-credit')).color) + check(L(`Hilfstext "--an-faint" kontrastreich (${faint})`), Number(/rgba?\([^)]*?,\s*([\d.]+)\)$/.exec(faint)?.[1] ?? 1) >= 0.55) + if (touch) { + const heights = await page.evaluate(() => Object.fromEntries(['.an-tabbtn', '.an-seg button', '.an-ltab', '.an-more', '.an-teaser .an-btn'].map((sel) => [sel, Math.min(...[...document.querySelectorAll(sel)].map((e) => e.getBoundingClientRect().height))]))) + check(L(`Tippflächen mindestens 40 px: ${Object.entries(heights).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(', ')}`), Object.values(heights).every((h) => h >= 39.5)) + } // Ranglisten: Umschalter und "Alle anzeigen" ändern nur ihre Karte check(L('Rangliste: 6 Zeilen und "Alle anzeigen"'), (await count('[data-rank="seen"] .an-lr')) === 6 && (await count('[data-rank="seen"] [data-rank-more]')) === 1) await page.click('[data-rank="seen"] [data-rank-more]') @@ -255,6 +281,16 @@ try { const settings = await text('#analyse-body') check(L('Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"'), (await page.isVisible('#an-off-ask')) && settings.includes('Analyse ausschalten') && /aktiv seit 12\.08\.2026/.test(settings) && settings.includes('Fassung 2026-10-02') && (await count('.an-docs a')) === 2 && (await count('.an-swatch')) === 1 && (await count('.an-top .an-seg')) === 0) check(L('kein_horizontaler_scroll einstellungen'), await noScroll()) + const errColor = await page.evaluate(() => { + const p = document.createElement('p') + p.className = 'error' + p.textContent = 'x' + document.querySelector('#an-off').appendChild(p) + const c = getComputedStyle(p).color + p.remove() + return c + }) + check(L(`Fehlertext beim Ausschalten rot (${errColor})`), errColor === 'rgb(252, 165, 165)') await shot('einstellungen') await page.click('#an-off-ask') await page.click('#an-off-yes') @@ -311,6 +347,7 @@ try { await shot('dev') await open('alt') + check(L('alt: kein "Heute bisher" (alter Collector), Stand bleibt'), /^Analyse aktiv · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status'))) check(L('alt: keine Chips, keine pageerror'), (await count('.an-delta')) === 0 && (await count('.an-prev')) === 0 && errors.length === 0 && (await text('.an-kpis')).includes('612')) await shot('alt') @@ -323,6 +360,13 @@ try { check(L('fehler: "Neu laden" fragt erneut an'), statsRequests.length === requestsBefore + 1) await shot('fehler') + for (const [sc, label5] of [['stark', 'vierstellige'], ['sehrStark', 'fünfstellige']]) { + await open(sc) + const big = await axis() + check(L(`${label5} y-Werte (${big.y}) nicht abgeschnitten, x-Werte überlappen nicht, Schrift ${big.px.toFixed(1)} px`), big.inside && big.gap && big.px >= 10.5 && big.pxMax <= 13) + await shot(sc === 'stark' ? 'skala-4-stellig' : 'skala-5-stellig') + } + await open('langerPfad') const name = await page.evaluate(() => { const n = document.querySelector('[data-rank="seen"] .an-name'); return { sw: n.scrollWidth, cw: n.clientWidth, title: n.title } }) check(L(`langer Pfad${touch ? ', 402 px' : ''}: document.scrollingElement.scrollWidth <= innerWidth`), (await noScroll()) && name.sw > name.cw && name.title.length > 100)