Files
Kontrollverlust/frontend/src/pages/StatsPage.tsx
T
irrlichtandClaude Opus 5.5 b6cf8c7fa0 Abstimmung Ja/Nein statt Links/Rechts
Modus-Werte yes/no statt left/right in DB, API und Statistik; Knöpfe
"Ja"/"Nein". Bestehende Stimmen müssen per SQL migriert werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-29 18:14:57 +02:00

105 lines
3.9 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { 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.
// 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="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>
</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 s = stats.data;
// Backend liefert die Tage absteigend -> für die Achse drehen.
const daily = s ? s.daily.slice().reverse() : [];
return (
<section>
<h2>Statistik</h2>
<h3>Auf einen Blick</h3>
<div id="stat-overview" className="statgrid">
{stats.isError && <p className="msg">{errMsg(stats.error, "Statistik konnte nicht geladen werden.")}</p>}
{s && (
<>
<NumCard value={s.users} label={s.users === 1 ? "Profil" : "Profile"} />
<NumCard value={s.entries} label="Beiträge" />
<NumCard value={s.toplevel} label="davon Threads" />
<NumCard value={s.replies} label="davon Antworten" />
<NumCard value={s.votes.yes} label="Stimmen Ja" />
<NumCard value={s.votes.no} label="Stimmen Nein" />
</>
)}
</div>
<h3>Reichweite</h3>
<p className="muted">
Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
„Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
</p>
<div id="stat-reach" className="statgrid">
{s && (
<>
<NumCard value={s.impressions_total} label="Aufrufe gesamt" />
<NumCard value={s.visitors_total} label="Netze gesamt" />
</>
)}
</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>
<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>
</section>
);
}