Statistik: tägliche SVG-Diagramme statt CSS-Balken

Der Server rendert einmal täglich (0:01 Uhr deutscher Zeit) Liniendiagramme
für Aufrufe, Netze und Beiträge pro Tag sowie gestapelte Säulen (Threads/
Antworten) für den Ø pro Wochentag und pro Tageszeit, je schmal (600) und
breit (1200), ausgeliefert über /api/stats/charts. Hovern zeigt den
Datenpunkt in einer Infozeile und markiert ihn mit einer senkrechten Linie.

Die Statistik-Seite wird auf breiten Bildschirmen bis 1200 px breit.
"Netze nach Betreiber" entfällt; neu ist die Kennzahl Ø Beiträge/Tag der
letzten 30 Tage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-10-06 20:13:30 +02:00
co-authored by Claude Opus 5.5
parent 047ad392d9
commit 07a2b64eff
9 changed files with 881 additions and 137 deletions
+4 -2
View File
@@ -13,7 +13,8 @@ function plural(n: number, one: string, many: string) {
// Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und
// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
export function Layout() {
const isHome = useLocation().pathname === "/";
const path = useLocation().pathname;
const isHome = path === "/";
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
return (
@@ -38,7 +39,8 @@ export function Layout() {
)}
</header>
<main>
{/* Die Statistik-Seite darf auf breiten Bildschirmen breiter werden. */}
<main className={path === "/stats" ? "wide" : undefined}>
<Outlet />
</main>
</>
+29 -32
View File
@@ -117,6 +117,10 @@ main {
margin: auto;
}
main.wide {
max-width: 1200px;
}
main > section,
main > div,
main > h2,
@@ -417,41 +421,34 @@ form input[type="file"] {
line-height: 1.1;
}
/* Horizontale Balken (Aufrufe/Tag, Betreiber). Breite setzt React per CSSOM
(kein Inline-style im HTML -> CSP-konform). */
.bars {
padding: 0.3rem 0.5rem 0.6rem;
/* Server-SVGs (charts.go): schmal 600, breit 1200 im viewBox-Raum. Ab 900 px
Fensterbreite die breite Fassung, damit die Schrift nicht zu groß wird. */
.chart {
padding: 0.3rem 0 0.6rem;
}
.bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.15rem 0;
.chart svg {
display: block;
width: 100%;
height: auto;
}
.bar-label {
flex: 0 0 7rem;
text-align: right;
font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.chart-wide {
display: none;
}
.bar-track {
flex: 1;
background: #eee;
/* Senkrechte Markierung des Tages unter dem Zeiger (Liniendiagramme). */
.chart g:hover .mark {
visibility: visible;
}
.bar-fill {
height: 1.1rem;
background: gray;
/* Infozeile unter dem Diagramm: Höhe immer reserviert. */
.chart-tip {
margin: 0.2rem 0 0;
padding-left: 0.5rem;
min-height: 1.3em;
}
.bar-value {
flex: 0 0 auto;
text-align: right;
font-size: 0.95rem;
}
/* ASN-Namen sind länger als ein Datum -> mehr Platz, Umbruch statt Ellipse. */
#stat-asns .bar-label {
flex-basis: 13rem;
white-space: normal;
@media (min-width: 900px) {
.chart-narrow {
display: none;
}
.chart-wide {
display: block;
}
}
+48 -42
View File
@@ -1,53 +1,55 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { StatsDetail } from "../types";
import type { ChartPair, StatsCharts, StatsDetail } from "../types";
// Statistik-Seite: Zahlen + einfache CSS-Balken (keine Chart-Library).
// Balkenbreiten setzt React per CSSOM (element.style), nicht als
// style-Attribut im HTML -> von der strikten CSP nicht betroffen.
// Statistik-Seite: Zahlen und Diagramme, die der Server einmal täglich als SVG
// rendert (charts.go). Die SVGs kommen ohne style-Attribute aus -> von der
// strikten CSP nicht betroffen.
// NumCard ist eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
function NumCard({ value, label }: { value: number; label: string }) {
return (
<div>
<span className="num">{value}</span>
<span className="num">{value.toLocaleString("de-DE", { maximumFractionDigits: 1, useGrouping: false })}</span>
<span className="muted">{label}</span>
</div>
);
}
// Bars zeichnet eine Liste {label, value} als horizontale Balken. Die Breite
// ist relativ zum größten Wert.
function Bars({ items, formatLabel }: {
items: { label: string; value: number }[];
formatLabel?: (label: string) => string;
}) {
if (items.length === 0) return <p className="muted">Noch keine Daten.</p>;
const max = Math.max(...items.map((it) => it.value), 1);
// Wertespalte für alle Zeilen gleich breit: an der längsten Zahl ausrichten
// (in ch), damit die Tracks bündig sind und nicht je nach Stellenzahl wandern.
const valWidth = Math.max(...items.map((it) => String(it.value).length));
return items.map((it) => (
<div className="bar-row" key={it.label}>
<span className="bar-label">{formatLabel ? formatLabel(it.label) : it.label}</span>
<div className="bar-track">
<div className="bar-fill" style={{ width: `${(it.value / max) * 100}%` }}></div>
</div>
<span className="bar-value" style={{ width: `${valWidth}ch` }}>{it.value}</span>
// Chart bettet ein Server-SVG inline ein, damit es die Schrift der Seite erbt.
// Der Inhalt stammt vollständig aus charts.go (nur Zahlen und feste Texte).
// Beide Größen stehen im DOM, das CSS zeigt je nach Fensterbreite eine.
// Die Infozeile darunter ist immer reserviert (kein Springen) und zeigt den
// data-tip des Datenpunkts unter dem Zeiger; Tippen genügt auf Touch-Geräten.
function Chart({ svg }: { svg?: ChartPair }) {
const [tip, setTip] = useState("");
if (!svg) return null;
const pick = (e: React.MouseEvent) => {
const el = (e.target as Element).closest("[data-tip]");
if (el) setTip(el.getAttribute("data-tip") ?? "");
};
return (
<div className="chart" onMouseOver={pick} onClick={pick} onMouseLeave={() => setTip("")}>
<div className="chart-narrow" dangerouslySetInnerHTML={{ __html: svg.narrow }} />
<div className="chart-wide" dangerouslySetInnerHTML={{ __html: svg.wide }} />
<p className="chart-tip">{tip || "\u00a0"}</p>
</div>
));
);
}
const dayAxis = (day: string) => day.slice(5); // "MM-DD" reicht auf der Achse
export function StatsPage() {
const stats = useQuery({
queryKey: ["stats"],
queryFn: () => api.get<StatsDetail>("/stats/detail"),
});
const charts = useQuery({
queryKey: ["stats-charts"],
queryFn: () => api.get<StatsCharts>("/stats/charts"),
staleTime: 10 * 60 * 1000,
});
const s = stats.data;
// Backend liefert die Tage absteigend -> für die Achse drehen.
const daily = s ? s.daily.slice().reverse() : [];
const c = charts.data?.charts;
return (
<section>
@@ -62,6 +64,7 @@ export function StatsPage() {
<NumCard value={s.entries} label="Beiträge" />
<NumCard value={s.toplevel} label="davon Threads" />
<NumCard value={s.replies} label="davon Antworten" />
<NumCard value={s.entries_per_day_30} label="Ø Beiträge/Tag (30 Tage)" />
<NumCard value={s.votes.yes} label="Stimmen Ja" />
<NumCard value={s.votes.no} label="Stimmen Nein" />
</>
@@ -82,23 +85,26 @@ export function StatsPage() {
)}
</div>
<h3>Netze nach Betreiber</h3>
<p className="muted">
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
</p>
<div id="stat-asns" className="bars">
{s && <Bars items={s.top_asns.map((a) => ({ label: a.asn, value: a.hits }))} />}
</div>
<h3>Aufrufe pro Tag</h3>
<div id="stat-daily" className="bars">
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.impressions }))} formatLabel={dayAxis} />}
</div>
<Chart svg={c?.impressions} />
<h3>Verschiedene Netze pro Tag</h3>
<div id="stat-networks" className="bars">
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.visitors }))} formatLabel={dayAxis} />}
</div>
<Chart svg={c?.networks} />
<h3>Beiträge pro Tag</h3>
<p className="muted">
Alle Beiträge einschließlich gelöschter, in deutscher Zeit. Wochentag und
Tageszeit zeigen den Durchschnitt pro Tag seit dem ersten Beitrag.
Die Diagramme werden einmal täglich erstellt und enden mit gestern.
</p>
{charts.isError && <p className="msg">{errMsg(charts.error, "Diagramme konnten nicht geladen werden.")}</p>}
<Chart svg={c?.entries} />
<h3>Beiträge pro Wochentag</h3>
<Chart svg={c?.weekday} />
<h3>Beiträge pro Tageszeit</h3>
<Chart svg={c?.hour} />
</section>
);
}
+13 -2
View File
@@ -79,8 +79,19 @@ export interface StatsDetail {
votes: { yes: number; no: number };
impressions_total: number;
visitors_total: number;
daily: { day: string; impressions: number; visitors: number }[];
top_asns: { asn: string; hits: number; networks: number }[];
entries_per_day_30: number;
}
// Fertig gerenderte SVG-Diagramme der Statistik-Seite (einmal täglich,
// charts.go), je schmal (600) und breit (1200).
export interface ChartPair {
narrow: string;
wide: string;
}
export interface StatsCharts {
generated: number;
charts: Record<"impressions" | "networks" | "entries" | "weekday" | "hour", ChartPair>;
}
// Fehlerformat: {"error": {...}}, bei manchen Fehlern mit Zusatzfeldern