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:
co-authored by
Claude Opus 5.5
parent
047ad392d9
commit
07a2b64eff
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user