Files
KantSites/templates/admin/statistics.html.twig
T
kantarciandClaude Fable 5.1 160c394c3f
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Rework forms, statistics, design and the sidebar foot in 1.15.0
Forms are an editor now: one card per field with add and remove, the
technical name derived from the label, options only for a choice, and a
new "Aussehen" section with a button label and four colours (field border
and background, button and button text) that travel to the page as one
rule per form inside the hashed style block. A live preview draws the
form with the site's default button and names the text contrast.

Statistics open with six figures (views against the same span before,
average per day, busiest day, pages, referred views, missing addresses),
a chart with grid lines and a title per bar, a weekday chart and ranked
lists with share bars. The design screen shows the palette as colour
cards with seven ready-made palettes, picks the base colours from the
palette, previews every font in itself and the typography as a sample.
The sidebar ends in an account card with role and a logout icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:59:30 +02:00

179 lines
12 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends 'admin/shell.html.twig' %}
{% set nav = 'statistics' %}
{% block title %}Statistik{% endblock %}
{% block heading %}Statistik{% endblock %}
{% block lead %}Gezählt wird auf dem Server, ohne Cookie und ohne IP-Adresse – deshalb braucht diese Messung keine Einwilligung. Zeiten in UTC.{% endblock %}
{% block actions %}
<nav class="range-switch" aria-label="Zeitraum">
{% for days, label in ranges %}
<a href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
{% endfor %}
</nav>
<a class="secondary" href="{{ path('admin_statistics_csv', {tage: range}) }}">Als CSV</a>
{% endblock %}
{% block content %}
{% set perDay = stats.total / stats.range %}
{% set delta = stats.previousTotal > 0 ? ((stats.total - stats.previousTotal) / stats.previousTotal * 100)|round : null %}
{% set weekdayNames = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] %}
{% set weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
{# Die Kennzahlen zuerst: was der Zeitraum gebracht hat, und woran man es misst. #}
<div class="stat-tiles">
<div class="stat-tile stat-tile-main">
<span class="stat-label">Seitenaufrufe</span>
<strong class="stat-value">{{ stats.total|number_format(0, ',', '.') }}</strong>
{% if delta is null %}
<span class="stat-note">{{ stats.total > 0 ? 'im Zeitraum davor keine Aufrufe' : 'noch nichts gezählt' }}</span>
{% else %}
<span class="stat-note stat-delta {{ delta > 0 ? 'stat-up' : (delta < 0 ? 'stat-down' : '') }}">{{ delta > 0 ? '+' : '' }}{{ delta|number_format(0, ',', '.') }} % gegenüber {{ ranges[range]|lower }} davor ({{ stats.previousTotal|number_format(0, ',', '.') }})</span>
{% endif %}
</div>
<div class="stat-tile">
<span class="stat-label">Im Schnitt je Tag</span>
<strong class="stat-value">{{ perDay|number_format(perDay < 10 ? 1 : 0, ',', '.') }}</strong>
<span class="stat-note">seit {{ stats.from|date('d.m.Y') }}</span>
</div>
<div class="stat-tile">
<span class="stat-label">Stärkster Tag</span>
<strong class="stat-value">{{ stats.busiestDay ? stats.busiest|number_format(0, ',', '.') : '–' }}</strong>
<span class="stat-note">{{ stats.busiestDay ? stats.busiestDay|date('d.m.Y') ~ ', ' ~ weekdayNames[stats.busiestDay|date('N') - 1] : 'kein Aufruf im Zeitraum' }}</span>
</div>
<div class="stat-tile">
<span class="stat-label">Aufgerufene Seiten</span>
<strong class="stat-value">{{ stats.pageCount|number_format(0, ',', '.') }}</strong>
<span class="stat-note">unterschiedliche Adressen</span>
</div>
<div class="stat-tile">
<span class="stat-label">Über Verweise</span>
<strong class="stat-value">{{ stats.referred|number_format(0, ',', '.') }}</strong>
<span class="stat-note">{{ stats.total > 0 ? (stats.referred / stats.total * 100)|round ~ ' % kamen von einer anderen Website' : 'von einer anderen Website' }}</span>
</div>
<div class="stat-tile">
<span class="stat-label">Nicht gefunden</span>
<strong class="stat-value">{{ stats.missingTotal|number_format(0, ',', '.') }}</strong>
<span class="stat-note">Aufrufe einer Adresse ohne Seite</span>
</div>
</div>
<div class="card">
<div class="card-head">
<h2>Verlauf <span class="count">{{ stats.total }}</span></h2>
<span class="muted">Aufrufe je Tag, {{ stats.from|date('d.m.') }} bis heute</span>
</div>
<div class="card-body">
{% if stats.total == 0 %}
<p class="muted">Noch keine Aufrufe in diesem Zeitraum. Gezählt wird erst, wenn eine veröffentlichte Seite tatsächlich abgerufen wird – Aufrufe der Verwaltung und erkennbare Suchmaschinen zählen nicht mit.</p>
{% else %}
{# Drawn by the server as an SVG: no chart library, no script, and it prints. The bars are
coloured by class, not by a style attribute, because inline styles are forbidden here.
The numbers themselves are in the table below, which is what a screen reader reads.
Jeder Balken traegt seinen Tag und seine Zahl als Titel - der Browser zeigt sie beim
Ueberfahren, ohne dass ein Skript etwas nachrechnen muss. #}
{% set width = stats.days|length * 12 %}
<svg class="chart chart-days" viewBox="0 0 {{ width }} 116" role="img"
aria-label="{{ stats.total }} Aufrufe zwischen {{ stats.from|date('d.m.Y') }} und heute, höchster Tageswert {{ stats.busiest }}" preserveAspectRatio="none">
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="{{ width }}" y2="{{ line }}.5"></line>{% endfor %}
{% for entry in stats.days %}
{% set height = entry.count > 0 ? (entry.count / stats.busiest * 100)|round(1) : 0 %}
<rect class="chart-bar{{ entry.day == stats.busiestDay ? ' chart-bar-peak' : '' }}" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ entry.day|date('d.m.Y') }}: {{ entry.count }} {{ entry.count == 1 ? 'Aufruf' : 'Aufrufe' }}</title></rect>
{% endfor %}
<line class="chart-axis" x1="0" y1="100.5" x2="{{ width }}" y2="100.5"></line>
</svg>
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>Skala bis {{ stats.busiest }} · stärkster Tag hervorgehoben</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
<details class="chart-table">
<summary>Zahlen je Tag</summary>
<div class="table-wrap">
<table class="table">
<caption class="visually-hidden">Aufrufe je Tag im gewählten Zeitraum</caption>
<thead><tr><th scope="col">Tag</th><th scope="col">Wochentag</th><th scope="col">Aufrufe</th></tr></thead>
<tbody>
{% for entry in stats.days|reverse %}
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ weekdayShort[entry.day|date('N') - 1] }}</td><td>{{ entry.count }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}
</div>
</div>
{% if stats.total > 0 %}
<div class="stats-columns stats-columns-2">
<div class="card">
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde</span></div>
<div class="card-body">
{% set busiestHour = max(stats.hours|map(entry => entry.count)|filter(count => count > 0)|default([1])) %}
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="288" y2="{{ line }}.5"></line>{% endfor %}
{% for entry in stats.hours %}
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
<rect class="chart-bar{{ entry.count == busiestHour and entry.count > 0 ? ' chart-bar-peak' : '' }}" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ entry.hour }} bis {{ entry.hour + 1 }} Uhr: {{ entry.count }} {{ entry.count == 1 ? 'Aufruf' : 'Aufrufe' }}</title></rect>
{% endfor %}
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
</svg>
<p class="chart-legend"><span>0 Uhr</span><span>6</span><span>12 Uhr</span><span>18</span><span>23 Uhr</span></p>
<p class="field-help">Nur die letzten {{ compactAfter }} Tage sind stundengenau; ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
</div>
</div>
<div class="card">
<div class="card-head"><h2>Wochentag</h2><span class="muted">Im Schnitt je Tag</span></div>
<div class="card-body">
{% set busiestWeekday = max(stats.weekdays|map(entry => entry.average)|filter(average => average > 0)|default([1])) %}
<svg class="chart chart-weekdays" viewBox="0 0 84 116" role="img" aria-label="Aufrufe je Wochentag im Schnitt" preserveAspectRatio="none">
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="84" y2="{{ line }}.5"></line>{% endfor %}
{% for entry in stats.weekdays %}
{% set height = entry.average > 0 ? (entry.average / busiestWeekday * 100)|round(1) : 0 %}
<rect class="chart-bar{{ entry.average == busiestWeekday and entry.average > 0 ? ' chart-bar-peak' : '' }}" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ weekdayNames[entry.weekday - 1] }}: im Schnitt {{ entry.average|number_format(1, ',', '.') }} Aufrufe ({{ entry.count }} gesamt)</title></rect>
{% endfor %}
<line class="chart-axis" x1="0" y1="100.5" x2="84" y2="100.5"></line>
</svg>
<p class="chart-legend chart-legend-even">{% for name in weekdayShort %}<span>{{ name }}</span>{% endfor %}</p>
<p class="field-help">Der Schnitt gleicht aus, dass ein Zeitraum nicht jeden Wochentag gleich oft enthält.</p>
</div>
</div>
</div>
{% endif %}
<div class="stats-columns">
{% for group in [
{title: 'Seiten', hint: 'Meistaufgerufen', rows: stats.pages, key: 'path', link: true},
{title: 'Verweise von außen', hint: 'Websites, die verlinkt haben', rows: stats.referrers, key: 'referrer_host', link: false},
{title: 'Nicht gefundene Adressen', hint: 'Kandidaten für eine Weiterleitung', rows: stats.missing, key: 'path', link: false}
] %}
<div class="card">
<div class="card-head"><h2>{{ group.title }}</h2><span class="muted">{{ group.hint }}</span></div>
<div class="card-body">
{% if group.rows is empty %}
<p class="muted">Keine Daten im Zeitraum.</p>
{% else %}
{% set top = (group.rows|first).count %}
<ol class="stats-list">
{% for row in group.rows %}
<li>
<span class="stats-rank" aria-hidden="true">{{ loop.index }}</span>
{% if group.link %}<a href="{{ row[group.key] }}">{{ row[group.key] }}</a>{% else %}<span>{{ row[group.key] }}</span>{% endif %}
<svg class="stats-share" viewBox="0 0 100 6" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="stats-share-bar" x="0" y="0" width="{{ (row.count / top * 100)|round(1) }}" height="6" rx="3"></rect></svg>
<strong>{{ row.count|number_format(0, ',', '.') }}</strong>
</li>
{% endfor %}
</ol>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<details class="card">
<summary>Was diese Zahlen nicht sagen</summary>
<div class="card-body">
<p class="muted">Gezählt werden <strong>Aufrufe</strong>, nicht Besucher: ohne Wiedererkennung lässt sich nicht sagen, ob zehn Aufrufe von zehn Personen oder von einer kommen. Es gibt deshalb auch keine Verweildauer, keine Absprungrate, keinen Klickweg durch die Website und kein Land. All das braucht ein Merkmal, das eine Person wiedererkennbar macht – und damit eine Einwilligung.</p>
<p class="field-help">Erkennbare Suchmaschinen und Überwachungsdienste werden anhand ihrer Kennung ausgeschlossen. Das ist eine Heuristik: wer sich als Browser ausgibt, wird mitgezählt. Die Zahl ist also „Aufrufe, die sich nicht als Maschine gemeldet haben“, nicht „Menschen“. Erfundene Adressen von Crawlern laufen gesammelt unter <code>(sonstige)</code>, damit die Tabelle nicht mit tausenden Zeilen wächst.</p>
<p class="field-help">Aufbewahrt wird {{ keepDays }} Tage, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. Danach wird gelöscht.</p>
</div>
</details>
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}