Analyse: Zeitraum = abgeschlossene Tage, "Heute bisher" getrennt, Feinschliff
Final-Review-Fixwelle der Cockpit-Übersicht:
- F1 Collector: Der Zeitraum sind die range Kalendertage bis gestern (Berliner Tag),
der Vorzeitraum die range Tage davor; heute steht getrennt in today { day, pv, visitors,
contacts }. Kein Einbruch mehr am rechten Rand von Diagramm und Mini-Verläufen,
kein falsches "x % weniger" tagsüber. Portal: weekdayStats bekommt die ganze Reihe
(completedDays entfällt), "Vergleich ab" = firstDay + 2 x range, Kopfzeile
"Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr" (alter Collector ohne today
bleibt ohne "Heute bisher").
- F2 "direkt kontaktiert" zählt nur Telefon, E-Mail und WhatsApp (auch im Vorzeitraum),
Links nach außen nicht.
- F3 Mini-Verlauf "Ø Zeit pro Seite": Tage ohne Messung sind Lücken statt 0-Tälern.
- F4 Fehlerfarbe beim Ausschalten rot, --an-faint auf 55 %, Achsenschrift per
Container-Abfragen auf 11-12 px (y-Rand wächst mit der Stellenzahl, x-Beschriftungen mit
Mindestabstand), Ranglisten-Anteile an allen Seitenaufrufen, Tippflächen bis 880 px
mindestens 40 px hoch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eadb768d1a
commit
0db59d38e9
11 files changed
+376
-135
No files matched your search
+46
-21
@@ -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 <b>), 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) => `<span class="an-st">${t}</span>`).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
|
||||
? `<select id="an-project" class="an-pick" aria-label="Website wählen">${sites.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project?.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>`
|
||||
@@ -92,19 +97,33 @@ export function summaryHtml(stats) {
|
||||
return `<section class="an-sum">${head}<p class="an-lead">${s.lead}</p>${points}</section>`
|
||||
}
|
||||
|
||||
/** 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 `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${path} L200 38 L0 38 Z"/><path class="an-spark-line" d="${path}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
|
||||
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 `<svg class="an-spark" viewBox="0 0 200 38" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="an-spark-area" d="${area}"/><path class="an-spark-line" d="${line}" fill="none" vector-effect="non-scaling-stroke"/></svg>`
|
||||
}
|
||||
|
||||
// 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)}<span class="an-vs">vs. ${esc(k.vs)}</span>` : !cmp.ok && cmp.from ? `<span class="an-vs">Vergleich ab ${esc(cmp.from)}</span>` : ''
|
||||
return `<div class="an-card an-kpi" data-kpi="${k.key}"><span class="an-k">${k.label}${k.star ? '<span class="an-star">*</span>' : ''}</span><b class="an-v">${esc(k.value)}</b>${k.sub ? `<small class="an-sub">${esc(k.sub)}</small>` : ''}<div class="an-cmp">${foot}</div>${sparkHtml(trend(stats?.series, field[k.key]))}</div>`
|
||||
return `<div class="an-card an-kpi" data-kpi="${k.key}"><span class="an-k">${k.label}${k.star ? '<span class="an-star">*</span>' : ''}</span><b class="an-v">${esc(k.value)}</b>${k.sub ? `<small class="an-sub">${esc(k.sub)}</small>` : ''}<div class="an-cmp">${foot}</div>${sparkHtml(trend(stats?.series, field[k.key], k.key === 'time'))}</div>`
|
||||
}
|
||||
return `<div class="an-kpis">${tiles.map(tile).join('')}</div>`
|
||||
}
|
||||
@@ -139,7 +158,11 @@ export function chartHtml(stats) {
|
||||
const cmp = compareFrom(stats)
|
||||
const head = (legend) => `<div class="an-head"><h3>Seitenaufrufe pro Tag</h3>${legend}</div>`
|
||||
if (!series.length) return `<section class="an-card an-chartbox">${head('')}<p class="an-empty">Noch keine Daten.</p></section>`
|
||||
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 = `<div class="an-legend"><span class="an-leg"><i class="an-leg-line"></i>${days} Tage</span>${hasPrev ? '<span class="an-leg"><i class="an-leg-line an-leg-prev"></i>Zeitraum davor</span>' : ''}</div>`
|
||||
const dots = m.points.map((p, i) => `<circle class="an-dot" data-i="${i}" cx="${p.x}" cy="${p.y}" r="3" data-day="${esc(p.day)}" data-pv="${p.pv}"${p.prev === null ? '' : ` data-prev="${p.prev}"`}/>`).join('')
|
||||
return `<section class="an-card an-chartbox">${head(legend)}<div class="an-chartwrap"><svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title"><title id="an-chart-title">${esc(title)}</title>`
|
||||
+ m.yTicks.map((k) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${k.y}" y2="${k.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${k.y + 4}" text-anchor="end">${esc(k.label)}</text>`).join('')
|
||||
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')
|
||||
+ m.yTicks.map((k) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${k.y}" y2="${k.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${k.y}" dy="0.35em" text-anchor="end">${esc(k.label)}</text>`).join('')
|
||||
+ m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 9}" text-anchor="middle">${esc(l.label)}</text>`).join('')
|
||||
+ `<path class="an-area" d="${m.area}"/>${hasPrev ? `<path class="an-prev" d="${m.prevLine}"/>` : ''}<path class="an-line" d="${m.line}"/>${dots}</svg><div class="an-tip" hidden></div></div></section>`
|
||||
}
|
||||
|
||||
@@ -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)} %`
|
||||
|
||||
Reference in new issue
Block a user