Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 21:34:43 +00:00
1 parent 9d1691cf6b
commit 69c94d83d7
2 files changed
+509 -5

No files matched your search

+251 -5
View File
@@ -1,5 +1,7 @@
/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
import { esc } from './fn-util.js'
// Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1
export function contrastOk(hex) {
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false
@@ -53,22 +55,33 @@ export function shortDay(day) {
return m ? `${m[2]}.${m[1]}.` : String(day || '')
}
/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */
export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) {
/** 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 } = {}) {
const data = Array.isArray(series) ? series : []
const before = Array.isArray(compare) ? compare : []
const prevOf = (i) => {
const v = before[i]?.pv
return v == null || !Number.isFinite(Number(v)) ? null : Number(v)
}
const innerW = width - pad.l - pad.r
const innerH = height - pad.t - pad.b
const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0)))
const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0), ...data.map((_, i) => prevOf(i) ?? 0)))
const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2)
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 }))
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 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)
const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : ''
return { width, height, pad, top, points, xLabels, yTicks, line, area }
// Vergleichslinie: Luecken (fehlende Tage) beginnen einen neuen Abschnitt
const prevLine = points.reduce((path, p, i) => {
if (p.prev === null) return path
const move = i > 0 && points[i - 1].prev !== null ? 'L' : 'M'
return `${path}${path ? ' ' : ''}${move}${p.x} ${round(y(p.prev))}`
}, '')
return { width, height, pad, top, points, xLabels, yTicks, line, area, prevLine }
}
function round(n) {
@@ -101,3 +114,236 @@ export function pickAnalyseProject(projects) {
// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js)
export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }
/* --- Vergleich zum Vorzeitraum und Klartext ("Das Wichtigste"), Spec 2.3 und 2.4 --- */
const MONTHS = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.']
const WEEKDAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] // wie Date#getUTCDay()
const WEEKDAYS_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.']
const AGAINST = 'gegenüber dem Zeitraum davor'
const NUMBER_DE = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 })
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
/** Ganzzahl auf Deutsch: 6930 => "6.930". */
export const fmtNum = (n) => NUMBER_DE.format(Number(n) || 0)
/** Sekunden als m:ss: 108 => "1:48", 7 => "0:07"; null oder ungueltig => "–". */
export function fmtDuration(sec) {
if (!isNum(sec) || sec < 0) return '–'
const s = Math.round(sec)
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}
// "YYYY-MM-DD" => { y, m, d } oder null; Wochentage und Tagesrechnung laufen nur ueber Date.UTC (keine Ortszeit)
function parseDay(day) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day || '')
if (!m) return null
const [y, mo, d] = [+m[1], +m[2], +m[3]]
const t = new Date(Date.UTC(y, mo - 1, d))
return t.getUTCMonth() === mo - 1 && t.getUTCDate() === d ? { y, m: mo, d } : null
}
const weekdayOf = (day) => {
const p = parseDay(day)
return p ? new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() : -1
}
function addDays(day, n) {
const p = parseDay(day)
return p ? new Date(Date.UTC(p.y, p.m - 1, p.d + n)).toISOString().slice(0, 10) : ''
}
/** "2026-09-19" => "Sa., 19.09." */
export function dayLabel(day) {
const w = weekdayOf(day)
return w < 0 ? String(day || '') : `${WEEKDAYS_SHORT[w]}, ${shortDay(day)}`
}
/** ("2026-09-05", "2026-10-04") => "5. Sep. – 4. Okt."; leere oder ungueltige Eingabe => "". */
export function periodLabel(from, to) {
const a = parseDay(from)
const b = parseDay(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). */
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)
return { ok: false, from: from ? shortDay(from) : '' }
}
return { ok: false, from: '' }
}
// Sekunden in Worten fuer Screenreader: 65 => "1 Minute 5 Sekunden"
function secWords(sec) {
const m = Math.floor(sec / 60)
const s = sec % 60
return [m ? `${m} ${m === 1 ? 'Minute' : 'Minuten'}` : '', s || !m ? `${s} ${s === 1 ? 'Sekunde' : 'Sekunden'}` : ''].filter(Boolean).join(' ')
}
/** Veraenderung gegenueber dem Vorwert fuer den Chip: { dir, text, aria } oder null, wenn ein Wert keine Zahl ist. kind 'count': Prozent ab 100/100, sonst absolut; kind 'sec': absolut in Sekunden. */
export function delta(cur, prev, { kind = 'count' } = {}) {
if (!isNum(cur) || !isNum(prev)) return null
const flat = (text) => ({ dir: 'flat', text, aria: `unverändert ${AGAINST}` })
const diff = cur - prev
if (diff === 0) return flat('± 0')
const dir = diff > 0 ? 'up' : 'down'
const arrow = diff > 0 ? '▲' : '▼'
const sign = diff > 0 ? 'plus' : 'minus'
if (kind === 'sec') {
const n = Math.round(Math.abs(diff))
if (n === 0) return flat('± 0')
return { dir, text: `${arrow} ${n < 60 ? `${n} s` : fmtDuration(n)}`, aria: `${sign} ${secWords(n)} ${AGAINST}` }
}
if (cur >= 100 && prev >= 100) {
const pct = (Math.abs(diff) / prev) * 100
if (pct < 1) return flat('± 0 %')
const n = Math.round(pct)
return { dir, text: `${arrow} ${n} %`, aria: `${sign} ${n} Prozent ${AGAINST}` }
}
const n = fmtNum(Math.abs(diff))
return { dir, text: `${arrow} ${n}`, aria: `${sign} ${n} ${AGAINST}` }
}
/** Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; null unter 30 Besuchern (zu unsicher). */
export function contactRate(contacts, visitors) {
if (!isNum(contacts) || !isNum(visitors) || visitors < 30) return null
return Math.round((contacts / visitors) * 1000) / 10
}
// Bekannte Herkunfts-Hosts (Teilstring am Label-Rand: "l.facebook.com" ja, "notgoogle.com" nein)
const REF_NAMES = [
[/(^|\.)google\.(com?\.)?[a-z]{2,3}$/, 'Google'],
[/(^|\.)bing\.com$/, 'Bing'],
[/(^|\.)instagram\.com$/, 'Instagram'],
[/(^|\.)facebook\.com$/, 'Facebook'],
[/(^|\.)duckduckgo\.com$/, 'DuckDuckGo'],
[/(^|\.)ecosia\.org$/, 'Ecosia'],
]
/** Lesbarer Name einer Herkunft; unbekannter Host bleibt unveraendert (beim Einsetzen in HTML maskieren). */
export function refName(host) {
const h = String(host ?? '').trim()
if (!h) return 'Direkt oder unbekannt'
const hit = REF_NAMES.find(([re]) => re.test(h.toLowerCase()))
return hit ? hit[1] : h
}
/** Staerkster Wochentag und Spitzentag ueber die Tage 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))
.map((d) => ({ day: d.day, pv: Number(d.pv) || 0 }))
if (rows.length < 7) return null
const avg = rows.reduce((a, r) => a + r.pv, 0) / rows.length
const perWeekday = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 }))
for (const r of rows) {
const w = perWeekday[weekdayOf(r.day)]
w.sum += r.pv
w.n += 1
}
let best = null
perWeekday.forEach((w, weekday) => {
if (w.n && (!best || w.sum / w.n > best.avg)) best = { weekday, name: WEEKDAYS[weekday], avg: w.sum / w.n }
})
if (!best || !(avg > 0) || best.avg < 1.2 * avg) best = null
const max = rows.reduce((m, r) => (r.pv > m.pv ? r : m), rows[0])
const peak = avg > 0 && max.pv >= 1.8 * avg ? { day: max.day, pv: max.pv } : null
return { best, avg, peak }
}
const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp', out: 'über einen Link nach außen' }
const DEVICE_WORDS = { m: 'vom Handy', d: 'vom Computer' }
// Satz ueber dem Hinweis-Block; nur hier steht fett (<b>), alle Zahlen sind fmtNum
function leadOf(stats, cmp) {
const days = Number(stats.range) || (Array.isArray(stats.series) ? stats.series.length : 0)
const pv = Number(stats.totals.pv)
const count = `<b>${fmtNum(pv)}-mal</b>`
if (!cmp.ok) {
return stats.firstDay && stats.from && stats.firstDay >= stats.from
? `Seit dem Start der Analyse am ${esc(shortDay(stats.firstDay))} wurde deine Website ${count} aufgerufen.`
: `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen.`
}
const before = Number(stats.previous.totals.pv)
const diff = pv - before
const start = `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen – `
if (Math.abs(diff) / before < 0.05) return `${start}etwa so oft wie in den ${days} Tagen davor.`
const more = diff > 0
const size = pv >= 100 && before >= 100
? `${fmtNum(Math.round((Math.abs(diff) / before) * 100))} %`
: `${fmtNum(Math.abs(diff))} ${Math.abs(diff) === 1 ? 'Aufruf' : 'Aufrufe'}`
return `${start}<b>${size} ${more ? 'mehr' : 'weniger'}</b> als in den ${days} Tagen davor.`
}
function contactItem(stats, cmp) {
const total = Number(stats.totals.contacts) || 0
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
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.`
}
return text
}
function dayItem(stats) {
if (!(Number(stats.totals.pv) >= 70)) return null
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.`)
if (w.peak) parts.push(`Spitzentag war ${dayLabel(w.peak.day)} mit ${fmtNum(w.peak.pv)} Aufrufen.`)
return parts.length ? parts.join(' ') : null
}
function sourceItem(stats) {
const refs = Array.isArray(stats.refs) ? stats.refs : []
const sum = refs.reduce((a, r) => a + (Number(r.count) || 0), 0)
const first = refs.find((r) => r.key)
if (!first || !(sum > 0) || first.count / sum < 0.2) return null
return `Die meisten, die über einen Link kamen, kommen von ${esc(refName(first.key))} (${fmtNum(first.count)}).`
}
function deviceItem(stats) {
const devices = Array.isArray(stats.devices) ? stats.devices : []
const sum = devices.reduce((a, d) => a + (Number(d.count) || 0), 0)
const main = devices.find((d) => DEVICE_WORDS[d.key] && sum > 0 && d.count / sum >= 0.55)
return main ? `${fmtNum(Math.round((main.count / sum) * 100))} % der Aufrufe kommen ${DEVICE_WORDS[main.key]}.` : null
}
function pageItem(stats) {
const [first, second] = Array.isArray(stats.pages) ? stats.pages : []
if (!first?.key) return null
if (first.key === '/') {
return second?.key
? `Am meisten angesehen: die Startseite, danach ${esc(second.key)} (${fmtNum(second.count)}).`
: `Am meisten angesehen: die Startseite (${fmtNum(first.count)} Aufrufe).`
}
return `Am meisten angesehen: ${esc(first.key)} (${fmtNum(first.count)} Aufrufe).`
}
/** Klartext fuer "Das Wichtigste": { lead, items: [{ icon, html }] } als maskiertes HTML; null unter 30 Seitenaufrufen. Feste Regeln, keine KI. */
export function summarize(stats) {
if (!stats?.totals || !(Number(stats.totals.pv) >= 30)) return null
const cmp = compareFrom(stats)
const items = [
['contact', contactItem(stats, cmp)],
['day', dayItem(stats)],
['source', sourceItem(stats)],
['device', deviceItem(stats)],
['page', pageItem(stats)],
].filter(([, html]) => html).slice(0, 4).map(([icon, html]) => ({ icon, html }))
return { lead: leadOf(stats, cmp), items }
}